commit 0b392a6953301ce7416131f6503719bbfcdb434e Author: Alex Date: Sat Jul 23 01:44:03 2022 +0200 🎉 First commit of the open source project ! diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e6df549 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +.dockerignore +# there are valid reasons to keep the .git, namely so that you can get the +# current commit hash +#.git +.log +tmp + +# Mix artifacts +_build +deps +*.ez +releases + +# Generate on crash by the VM +erl_crash.dump + +# Static artifacts +node_modules + +assets/node_modules/ +deps/ +assets/yarn.lock \ No newline at end of file diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..b6c4dd4 --- /dev/null +++ b/.env.sample @@ -0,0 +1,6 @@ +AWS_ACCESS_KEY_ID=xxx +AWS_SECRET_ACCESS_KEY=xxx +AWS_REGION=eu-west-3 +AWS_PRES_BUCKET=xxx +PRESENTATION_STORAGE=local +MAIL=local \ No newline at end of file diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..8a6391c --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,5 @@ +[ + import_deps: [:ecto, :phoenix], + inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], + subdirectories: ["priv/*/migrations"] +] diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..e6122d8 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,42 @@ +name: Docker Image CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up QEMU + id: qemu + uses: docker/setup-qemu-action@v1 + with: + image: tonistiigi/binfmt:latest + platforms: all + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + with: + buildkitd-flags: --debug + driver-opts: image=moby/buildkit:v0.9.1 + - name: Log in to registry + # This is where you will update the PAT to GITHUB_TOKEN + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u $ --password-stdin + - name: Build and push Docker images + # You may pin to the exact commit or the version. + # uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a + uses: docker/build-push-action@v2.10.0 + with: + context: . + push: true + tags: ghcr.io/${{ github.repository }}/claper + cache-from: type=registry,ref=ghcr.io/${{ github.repository }}/claper:latest + cache-to: type=inline + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..383c279 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# 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 + +# Ignore package tarball (built via "mix hex.build"). +claper-*.tar + +# Ignore assets that are produced by build tools. +/priv/static/assets/ + +/priv/static/uploads + +# 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/ + +priv/static/images/.DS_Store +priv/static/.DS_Store +.env +priv/static/fonts/.DS_Store +test/e2e/node_modules +.DS_Store +priv/static/.well-known/apple-developer-merchantid-domain-association +priv/static/loaderio-eb3b956a176cdd4f54eb8570ce8bbb06.txt diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..be4fab4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,100 @@ +# 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-20210902-slim - for the release image +# - https://pkgs.org/ - resource for finding needed packages +# - Ex: hexpm/elixir:1.13.2-erlang-24.2.1-debian-bullseye-20210902-slim +# +ARG BUILDER_IMAGE="hexpm/elixir:1.13.2-erlang-24.2.1-debian-bullseye-20210902-slim" +ARG RUNNER_IMAGE="debian:bullseye-20210902-slim" + +FROM ${BUILDER_IMAGE} as builder + +# install build dependencies +RUN apt-get update -y && apt-get install -y build-essential git nodejs npm \ + && apt-get clean && rm -f /var/lib/apt/lists/*_* + +# prepare build dir +WORKDIR /app + +# install hex + rebar +RUN mix local.hex --force && \ + mix local.rebar --force + +# set build ENV +ENV MIX_ENV="prod" + +# install mix dependencies +COPY mix.exs mix.lock ./ +RUN mix deps.get --only $MIX_ENV +RUN mkdir config + +# 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 + +# note: if your project uses a tool like https://purgecss.com/, +# which customizes asset compilation based on what it finds in +# your Elixir templates, you will need to move the asset compilation +# step down so that `lib` is available. +COPY assets assets + +# Compile the release +COPY lib lib + +RUN mix compile + +# compile assets +RUN mix assets.deploy + +# 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 ghostscript \ + && apt-get install -y libreoffice --no-install-recommends && 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 +ENV HOME "/home/nobody" + +RUN mkdir /home/nobody && chown nobody /home/nobody + +WORKDIR "/app" +RUN chown nobody /app + +# Only copy the final release from the build stage +COPY --from=builder --chown=nobody:root /app/_build/prod/rel/claper ./ + +RUN chmod +x /app/bin/* + +USER nobody + +EXPOSE 4000 + +CMD ["sh", "-c", "/app/bin/claper eval Claper.Release.migrate && /app/bin/claper start"] + +# Appended by flyctl +#ENV ECTO_IPV6 true +#ENV ERL_AFLAGS "-proto_dist inet6_tcp" diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..6b5b9b3 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + Claper, tool to let your audience interact during your presentations. + Copyright (C) 2022 Alexandr Lion + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Claper Copyright (C) 2022 Alexandre Lion + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..98630a5 --- /dev/null +++ b/README.md @@ -0,0 +1,201 @@ + +[![Contributors][contributors-shield]][contributors-url] +[![Forks][forks-shield]][forks-url] +[![Stargazers][stars-shield]][stars-url] +[![Issues][issues-shield]][issues-url] +[![MIT License][license-shield]][license-url] + + +
+
+ + Logo + + +

Claper

+ +

+ The ultimate tool to interact with your audience. +
+ Explore the docs » +
+
+ Report Bug + · + Request Feature +

+
+ + + + +
+ Table of Contents +
    +
  1. + About The Project + +
  2. +
  3. + Getting Started + +
  4. +
  5. Usage
  6. +
  7. Roadmap
  8. +
  9. Contributing
  10. +
  11. License
  12. +
  13. Contact
  14. +
+
+ + + + +## About The Project + +[![Product Name Screen Shot][product-screenshot]](https://claper.co) + +Claper turns your presentations into an interactive, engaging and exciting experience. + +Claper has a two-sided mission: +- The first one is to help these people presenting an idea or a message by giving them the opportunity to make their presentation unique and to have real-time feedback from their audience. +- The second one is to help each participant to take their place, to be an actor in the presentation, in the meeting and to feel important and useful. + + +### Built With + +Claper is proudly powered by Phoenix and Elixir. + +* [![Phoenix][Phoenix]][Phoenix-url] +* [![Elixir][Elixir]][Elixir-url] +* [![Tailwind][Tailwind]][Tailwind-url] + + + +## Getting Started + +This is an example of how you may give instructions on setting up your project locally. +To get a local copy up and running follow these simple example steps. + +### Prerequisites + +To run Claper on your local environment you need to have: +* Postgres >= 9 +* Elixir >= 1.13.2 +* Erland >= 24 + +You can also use Docker to easily run a Postgres instance: +```sh + docker run -p 5432:5432 -e POSTGRES_PASSWORD=claper -e POSTGRES_USER=claper -e POSTGRES_DB=claper --name claper-db -d postgres:9 + ``` + +### Configuration + +All configuration used by the app is store on the `.env` file. You can find an example file in `.env.sample`, but you should copy it to `.env` and fill it with your own values. + +- **PRESENTATION_STORAGE** : `local` or `s3`, define where the presentation files will be stored. +- **MAIL** : `local` or `smtp`, define how the mails will be sent. + +_(only if s3 is used)_ : +- **AWS_ACCESS_KEY_ID** : Your AWS Access Key ID. +- **AWS_SECRET_ACCESS_KEY** : Your AWS Secret Access Key. +- **AWS_S3_BUCKET** : The name of the bucket where the presentation files will be stored. +- **AWS_S3_REGION** : The region where the bucket is located. + +### Installation + + + +1. Clone the repo + ```sh + git clone https://github.com/ClaperCo/Claper.git + ``` +2. Install dependencies + ```sh + mix deps.get + ``` +3. Migrate your database + ```sh + mix ecto.migrate + ``` +4. Start Phoenix endpoint with + ```sh + mix phx.server + ``` + +Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. + +If you have configured `MAIL` to `local`, you can access to the mailbox at [`localhost:4000/dev/mailbox`](http://localhost:4000/dev/mailbox). + + + +## Usage + +### Login/Signup +Claper is passwordless, so you don't have to create an account. Just login with your email, check your mailbox ([localhost:4000/dev/mailbox](http://localhost:4000/dev/mailbox) if you have configured mail to be in local) and click on the link to get connected. + + +## Roadmap + +- [ ] Add Changelog +- [ ] Remove dead code +- [ ] Add additional tests for better coverage +- [ ] Add more docs + +See the [open issues](https://github.com/ClaperCo/Claper/issues) for a full list of proposed features (and known issues). + + + +## Contributing + +Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. + +If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". +Don't forget to give the project a star! Thanks again! + +1. Fork the Project +2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) +3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the Branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + + + +## License + +Distributed under the GPLv3 License. See `LICENSE.txt` for more information. + + +## Contact + +[![](https://img.shields.io/badge/@alexlionco-1DA1F2?style=for-the-badge&logo=twitter&logoColor=white)](https://twitter.com/alexlionco) + +Project Link: [https://github.com/ClaperCo/Claper](https://github.com/ClaperCo/Claper) + + + + + +[contributors-shield]: https://img.shields.io/github/contributors/ClaperCo/Claper.svg?style=for-the-badge +[contributors-url]: https://github.com/ClaperCo/Claper/graphs/contributors +[forks-shield]: https://img.shields.io/github/forks/ClaperCo/Claper.svg?style=for-the-badge +[forks-url]: https://github.com/ClaperCo/Claper/network/members +[stars-shield]: https://img.shields.io/github/stars/ClaperCo/Claper.svg?style=for-the-badge +[stars-url]: https://github.com/ClaperCo/Claper/stargazers +[issues-shield]: https://img.shields.io/github/issues/ClaperCo/Claper.svg?style=for-the-badge +[issues-url]: https://github.com/ClaperCo/Claper/issues +[license-shield]: https://img.shields.io/github/license/ClaperCo/Claper.svg?style=for-the-badge +[license-url]: https://github.com/ClaperCo/Claper/blob/master/LICENSE.txt +[product-screenshot]: /priv/static/images/preview.png +[Elixir]: https://img.shields.io/badge/elixir-4B275F?style=for-the-badge&logo=elixir&logoColor=white +[Elixir-url]: https://elixir-lang.org/ +[Tailwind]: https://img.shields.io/badge/tailwind-06B6D4?style=for-the-badge&logo=tailwindcss&logoColor=white +[Tailwind-url]: https://tailwindcss.com/ +[Phoenix]: https://img.shields.io/badge/phoenix-f35424?style=for-the-badge&logo=&logoColor=white +[Phoenix-url]: https://www.phoenixframework.org/ diff --git a/assets/build.js b/assets/build.js new file mode 100644 index 0000000..f487d99 --- /dev/null +++ b/assets/build.js @@ -0,0 +1,50 @@ +const esbuild = require('esbuild') + +const args = process.argv.slice(2) +const watch = args.includes('--watch') +const deploy = args.includes('--deploy') + +const loader = { + // Add loaders for images/fonts/etc, e.g. { '.svg': 'file' } +} + +const plugins = [ + // Add and configure plugins here +] + +let opts = { + entryPoints: ['js/app.js'], + bundle: true, + target: 'es2016', + outdir: '../priv/static/assets', + logLevel: 'info', + loader, + plugins +} + +if (watch) { + opts = { + ...opts, + watch, + sourcemap: 'inline' + } +} + +if (deploy) { + opts = { + ...opts, + minify: true + } +} + +const promise = esbuild.build(opts) + +if (watch) { + promise.then(_result => { + process.stdin.on('close', () => { + process.exit(0) + }) + + process.stdin.resume() + }) +} \ No newline at end of file diff --git a/assets/css/app.css b/assets/css/app.css new file mode 100644 index 0000000..565b018 --- /dev/null +++ b/assets/css/app.css @@ -0,0 +1,430 @@ +@import url("flatpickr/dist/flatpickr.min.css"); +@import url("animate.css/animate.min.css"); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +* { + font-family: 'Roboto', sans-serif; +} + +[x-cloak] { display: none !important; } + +/* Alerts and form errors used by phx.new */ +.alert { + padding: 15px; + margin-bottom: 20px; + border: 1px solid transparent; + border-radius: 4px; +} +.alert-info { + color: #31708f; + background-color: #d9edf7; + border-color: #bce8f1; +} +.alert-warning { + color: #8a6d3b; + background-color: #fcf8e3; + border-color: #faebcc; +} +.alert-danger { + color: #a94442; + background-color: #f2dede; + border-color: #ebccd1; +} +.alert p { + margin-bottom: 0; +} +.alert:empty { + display: none; +} +.invalid-feedback { + color: #a94442; + display: block; + margin-top: 2px; +} + +/* LiveView specific classes for your customization */ +.phx-no-feedback.invalid-feedback, +.phx-no-feedback .invalid-feedback { + display: none; +} + +.phx-click-loading { + opacity: 0.5; + transition: opacity 1s ease-out; +} + +.phx-disconnected{ + cursor: wait; +} +.phx-disconnected *{ + pointer-events: none; +} + +.phx-modal { + opacity: 1!important; + position: fixed; + z-index: 1; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgb(0,0,0); + background-color: rgba(0,0,0,0.4); +} + +.phx-modal-content { + background-color: #fefefe; + margin: 15vh auto; + padding: 20px; + border: 1px solid #888; + width: 80%; +} + +.phx-modal-close { + color: #aaa; + float: right; + font-size: 28px; + font-weight: bold; +} + +.phx-modal-close:hover, +.phx-modal-close:focus { + color: black; + text-decoration: none; + cursor: pointer; +} + +/** + Forms + **/ +.input:focus~.label, +.input:active~.label, +.input.filled~.label { + @apply text-sm transition-all duration-100 -top-1.5 text-primary-500; +} + +.input:focus~.icon, +.input:active~.icon, +.input.filled~.icon { + @apply transition-all duration-100 left-3 top-6 h-5; +} + +.date-placeholder-hidden::-webkit-datetime-edit { + display: none; +} + + +.date-placeholder-hidden::-webkit-inner-spin-button, +.date-placeholder-hidden::-webkit-calendar-picker-indicator { + display: none; + -webkit-appearance: none; +} + +/** + Custom fonts +**/ +/* roboto-100 - latin */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 100; + src: url('/fonts/Roboto/roboto-v29-latin-100.eot'); /* IE9 Compat Modes */ + src: local(''), + url('/fonts/Roboto/roboto-v29-latin-100.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ + url('/fonts/Roboto/roboto-v29-latin-100.woff2') format('woff2'), /* Super Modern Browsers */ + url('/fonts/Roboto/roboto-v29-latin-100.woff') format('woff'), /* Modern Browsers */ + url('/fonts/Roboto/roboto-v29-latin-100.ttf') format('truetype'), /* Safari, Android, iOS */ + url('/fonts/Roboto/roboto-v29-latin-100.svg#Roboto') format('svg'); /* Legacy iOS */ +} +/* roboto-100italic - latin */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 100; + src: url('/fonts/Roboto/roboto-v29-latin-100italic.eot'); /* IE9 Compat Modes */ + src: local(''), + url('/fonts/Roboto/roboto-v29-latin-100italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ + url('/fonts/Roboto/roboto-v29-latin-100italic.woff2') format('woff2'), /* Super Modern Browsers */ + url('/fonts/Roboto/roboto-v29-latin-100italic.woff') format('woff'), /* Modern Browsers */ + url('/fonts/Roboto/roboto-v29-latin-100italic.ttf') format('truetype'), /* Safari, Android, iOS */ + url('/fonts/Roboto/roboto-v29-latin-100italic.svg#Roboto') format('svg'); /* Legacy iOS */ +} +/* roboto-300italic - latin */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 300; + src: url('/fonts/Roboto/roboto-v29-latin-300italic.eot'); /* IE9 Compat Modes */ + src: local(''), + url('/fonts/Roboto/roboto-v29-latin-300italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ + url('/fonts/Roboto/roboto-v29-latin-300italic.woff2') format('woff2'), /* Super Modern Browsers */ + url('/fonts/Roboto/roboto-v29-latin-300italic.woff') format('woff'), /* Modern Browsers */ + url('/fonts/Roboto/roboto-v29-latin-300italic.ttf') format('truetype'), /* Safari, Android, iOS */ + url('/fonts/Roboto/roboto-v29-latin-300italic.svg#Roboto') format('svg'); /* Legacy iOS */ +} +/* roboto-300 - latin */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 300; + src: url('/fonts/Roboto/roboto-v29-latin-300.eot'); /* IE9 Compat Modes */ + src: local(''), + url('/fonts/Roboto/roboto-v29-latin-300.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ + url('/fonts/Roboto/roboto-v29-latin-300.woff2') format('woff2'), /* Super Modern Browsers */ + url('/fonts/Roboto/roboto-v29-latin-300.woff') format('woff'), /* Modern Browsers */ + url('/fonts/Roboto/roboto-v29-latin-300.ttf') format('truetype'), /* Safari, Android, iOS */ + url('/fonts/Roboto/roboto-v29-latin-300.svg#Roboto') format('svg'); /* Legacy iOS */ +} +/* roboto-regular - latin */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + src: url('/fonts/Roboto/roboto-v29-latin-regular.eot'); /* IE9 Compat Modes */ + src: local(''), + url('/fonts/Roboto/roboto-v29-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ + url('/fonts/Roboto/roboto-v29-latin-regular.woff2') format('woff2'), /* Super Modern Browsers */ + url('/fonts/Roboto/roboto-v29-latin-regular.woff') format('woff'), /* Modern Browsers */ + url('/fonts/Roboto/roboto-v29-latin-regular.ttf') format('truetype'), /* Safari, Android, iOS */ + url('/fonts/Roboto/roboto-v29-latin-regular.svg#Roboto') format('svg'); /* Legacy iOS */ +} +/* roboto-italic - latin */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 400; + src: url('/fonts/Roboto/roboto-v29-latin-italic.eot'); /* IE9 Compat Modes */ + src: local(''), + url('/fonts/Roboto/roboto-v29-latin-italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ + url('/fonts/Roboto/roboto-v29-latin-italic.woff2') format('woff2'), /* Super Modern Browsers */ + url('/fonts/Roboto/roboto-v29-latin-italic.woff') format('woff'), /* Modern Browsers */ + url('/fonts/Roboto/roboto-v29-latin-italic.ttf') format('truetype'), /* Safari, Android, iOS */ + url('/fonts/Roboto/roboto-v29-latin-italic.svg#Roboto') format('svg'); /* Legacy iOS */ +} +/* roboto-500 - latin */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 500; + src: url('/fonts/Roboto/roboto-v29-latin-500.eot'); /* IE9 Compat Modes */ + src: local(''), + url('/fonts/Roboto/roboto-v29-latin-500.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ + url('/fonts/Roboto/roboto-v29-latin-500.woff2') format('woff2'), /* Super Modern Browsers */ + url('/fonts/Roboto/roboto-v29-latin-500.woff') format('woff'), /* Modern Browsers */ + url('/fonts/Roboto/roboto-v29-latin-500.ttf') format('truetype'), /* Safari, Android, iOS */ + url('/fonts/Roboto/roboto-v29-latin-500.svg#Roboto') format('svg'); /* Legacy iOS */ +} +/* roboto-500italic - latin */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 500; + src: url('/fonts/Roboto/roboto-v29-latin-500italic.eot'); /* IE9 Compat Modes */ + src: local(''), + url('/fonts/Roboto/roboto-v29-latin-500italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ + url('/fonts/Roboto/roboto-v29-latin-500italic.woff2') format('woff2'), /* Super Modern Browsers */ + url('/fonts/Roboto/roboto-v29-latin-500italic.woff') format('woff'), /* Modern Browsers */ + url('/fonts/Roboto/roboto-v29-latin-500italic.ttf') format('truetype'), /* Safari, Android, iOS */ + url('/fonts/Roboto/roboto-v29-latin-500italic.svg#Roboto') format('svg'); /* Legacy iOS */ +} +/* roboto-700 - latin */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 700; + src: url('/fonts/Roboto/roboto-v29-latin-700.eot'); /* IE9 Compat Modes */ + src: local(''), + url('/fonts/Roboto/roboto-v29-latin-700.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ + url('/fonts/Roboto/roboto-v29-latin-700.woff2') format('woff2'), /* Super Modern Browsers */ + url('/fonts/Roboto/roboto-v29-latin-700.woff') format('woff'), /* Modern Browsers */ + url('/fonts/Roboto/roboto-v29-latin-700.ttf') format('truetype'), /* Safari, Android, iOS */ + url('/fonts/Roboto/roboto-v29-latin-700.svg#Roboto') format('svg'); /* Legacy iOS */ +} +/* roboto-700italic - latin */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 700; + src: url('/fonts/Roboto/roboto-v29-latin-700italic.eot'); /* IE9 Compat Modes */ + src: local(''), + url('/fonts/Roboto/roboto-v29-latin-700italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ + url('/fonts/Roboto/roboto-v29-latin-700italic.woff2') format('woff2'), /* Super Modern Browsers */ + url('/fonts/Roboto/roboto-v29-latin-700italic.woff') format('woff'), /* Modern Browsers */ + url('/fonts/Roboto/roboto-v29-latin-700italic.ttf') format('truetype'), /* Safari, Android, iOS */ + url('/fonts/Roboto/roboto-v29-latin-700italic.svg#Roboto') format('svg'); /* Legacy iOS */ +} +/* roboto-900 - latin */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 900; + src: url('/fonts/Roboto/roboto-v29-latin-900.eot'); /* IE9 Compat Modes */ + src: local(''), + url('/fonts/Roboto/roboto-v29-latin-900.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ + url('/fonts/Roboto/roboto-v29-latin-900.woff2') format('woff2'), /* Super Modern Browsers */ + url('/fonts/Roboto/roboto-v29-latin-900.woff') format('woff'), /* Modern Browsers */ + url('/fonts/Roboto/roboto-v29-latin-900.ttf') format('truetype'), /* Safari, Android, iOS */ + url('/fonts/Roboto/roboto-v29-latin-900.svg#Roboto') format('svg'); /* Legacy iOS */ +} +/* roboto-900italic - latin */ +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 900; + src: url('/fonts/Roboto/roboto-v29-latin-900italic.eot'); /* IE9 Compat Modes */ + src: local(''), + url('/fonts/Roboto/roboto-v29-latin-900italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ + url('/fonts/Roboto/roboto-v29-latin-900italic.woff2') format('woff2'), /* Super Modern Browsers */ + url('/fonts/Roboto/roboto-v29-latin-900italic.woff') format('woff'), /* Modern Browsers */ + url('/fonts/Roboto/roboto-v29-latin-900italic.ttf') format('truetype'), /* Safari, Android, iOS */ + url('/fonts/Roboto/roboto-v29-latin-900italic.svg#Roboto') format('svg'); /* Legacy iOS */ +} + +.bg-gradient-animate { + background: linear-gradient(-45deg, #b80fef, #8611ed, #14bfdb, #14bfdb); + background-size: 400% 400%; + animation: gradient 15s ease infinite; +} + +@keyframes gradient { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } +} + +.arrow_box { + position: relative; + background: #fff; + border: 2px solid #e1e1e1; +} +.arrow_box.arrow_right:after, .arrow_box.arrow_right:before { + left: 100%; + top: 50%; + border: solid transparent; + content: ""; + height: 0; + width: 0; + position: absolute; + pointer-events: none; +} + +.arrow_box.arrow_right:after { + border-color: rgba(255, 255, 255, 0); + border-left-color: #fff; + border-width: 15px; + margin-top: -15px; +} +.arrow_box.arrow_right:before { + border-color: rgba(225, 225, 225, 0); + border-left-color: #e1e1e1; + border-width: 18px; + margin-top: -18px; +} + +.arrow_box.arrow_left:after, .arrow_box.arrow_left:before { + right: 100%; + top: 50%; + border: solid transparent; + content: ""; + height: 0; + width: 0; + position: absolute; + pointer-events: none; +} + +.arrow_box.arrow_left:after { + border-color: rgba(255, 255, 255, 0); + border-right-color: #fff; + border-width: 15px; + margin-top: -15px; +} +.arrow_box.arrow_left:before { + border-color: rgba(225, 225, 225, 0); + border-right-color: #e1e1e1; + border-width: 18px; + margin-top: -18px; +} + +:root { + --animate-duration: 0.3s; +} + +/* Presenter fix for Safari */ + +@media not all and (min-resolution:.001dpcm) +{ @supports (-webkit-appearance:none) and (stroke-color:transparent) { +#post-list.showed { + @apply w-4/12; +} +}} +.react-animation { + opacity: 0; +} + +.react-animation:nth-child(odd) { + animation: react 2s linear; +} + +.react-animation:nth-child(even) { + animation: react2 2s linear; +} + +@keyframes react { + 0% { + transform: translateY(0%); + opacity: 0; + } + 20% { + transform: translateY(-70%); + opacity: 1; + } + 100% { + transform: translateY(-300%); + opacity: 0; + } +} + +@keyframes react2 { + 0% { + transform: translateY(0%); + opacity: 0; + } + 40% { + transform: translateY(-70%); + opacity: 1; + } + 100% { + transform: translateY(-500%); + opacity: 0; + } +} + +.strikethrough { + position: relative; +} + +.strikethrough:before { + position: absolute; + content: ""; + left: 0; + top: 50%; + right: 0; + border-top: 3px solid; + @apply border-supporting-red-600; + + -webkit-transform:rotate(-20deg); + -moz-transform:rotate(-20deg); + -ms-transform:rotate(-20deg); + -o-transform:rotate(-20deg); + transform:rotate(-20deg); +} \ No newline at end of file diff --git a/assets/css/custom.scss b/assets/css/custom.scss new file mode 100644 index 0000000..6fc8cb3 --- /dev/null +++ b/assets/css/custom.scss @@ -0,0 +1,44 @@ +@use "sass:math"; + +@import "../node_modules/tiny-slider/src/tiny-slider.scss"; + +$particleSize: 20vmin; +$animationDuration: 6s; +$amount: 20; +.background span { + width: $particleSize; + height: $particleSize; + border-radius: $particleSize; + backface-visibility: hidden; + opacity: 0.5; + position: absolute; + animation-name: move; + animation-duration: $animationDuration; + animation-timing-function: linear; + animation-iteration-count: infinite; + $colors: ( + #14bfdb, + #8611ed, + #b80fef + ); + @for $i from 1 through $amount { + &:nth-child(#{$i}) { + color: nth($colors, random(length($colors))); + top: random(100) * 1%; + left: random(100) * 1%; + animation-duration: math.div(random($animationDuration * 10), 10) * 1s + 10s; + animation-delay: math.div(random($animationDuration + 10s) * 10, 10) * -1s; + transform-origin: (random(50) ) * 1vw (random(50)) * 1vh; + $blurRadius: (random() + 0.9) * $particleSize * 0.9; + $x: if(random() > 0.5, -1, 1); + box-shadow: ($particleSize * 2 * $x) 0 $blurRadius currentColor; + } + } +} + +@keyframes move { + 100% { + transform: translate3d(0, 0, 1px) rotate(360deg); + } +} + diff --git a/assets/images/client-login.jpg b/assets/images/client-login.jpg new file mode 100644 index 0000000..5314627 Binary files /dev/null and b/assets/images/client-login.jpg differ diff --git a/assets/images/icons/arrow-white.svg b/assets/images/icons/arrow-white.svg new file mode 100644 index 0000000..0f7523e --- /dev/null +++ b/assets/images/icons/arrow-white.svg @@ -0,0 +1,32 @@ + + + + + + + + + diff --git a/assets/images/icons/arrow.svg b/assets/images/icons/arrow.svg new file mode 100644 index 0000000..f68656b --- /dev/null +++ b/assets/images/icons/arrow.svg @@ -0,0 +1,32 @@ + + + + + + + + + diff --git a/assets/images/icons/chatbubble-ellipses-outline.svg b/assets/images/icons/chatbubble-ellipses-outline.svg new file mode 100644 index 0000000..aea8925 --- /dev/null +++ b/assets/images/icons/chatbubble-ellipses-outline.svg @@ -0,0 +1 @@ +Chatbubble Ellipses \ No newline at end of file diff --git a/assets/images/icons/create-outline.svg b/assets/images/icons/create-outline.svg new file mode 100644 index 0000000..b286f7c --- /dev/null +++ b/assets/images/icons/create-outline.svg @@ -0,0 +1 @@ +Create \ No newline at end of file diff --git a/assets/images/icons/ellipsis-horizontal-white.svg b/assets/images/icons/ellipsis-horizontal-white.svg new file mode 100644 index 0000000..db248ab --- /dev/null +++ b/assets/images/icons/ellipsis-horizontal-white.svg @@ -0,0 +1 @@ +Ellipsis Horizontal \ No newline at end of file diff --git a/assets/images/icons/ellipsis-horizontal.svg b/assets/images/icons/ellipsis-horizontal.svg new file mode 100644 index 0000000..dc879be --- /dev/null +++ b/assets/images/icons/ellipsis-horizontal.svg @@ -0,0 +1 @@ +Ellipsis Horizontal \ No newline at end of file diff --git a/assets/images/icons/email.png b/assets/images/icons/email.png new file mode 100644 index 0000000..fd2e231 Binary files /dev/null and b/assets/images/icons/email.png differ diff --git a/assets/images/icons/exit-outline.svg b/assets/images/icons/exit-outline.svg new file mode 100644 index 0000000..e0712b6 --- /dev/null +++ b/assets/images/icons/exit-outline.svg @@ -0,0 +1 @@ +Exit \ No newline at end of file diff --git a/assets/images/icons/hashtag.svg b/assets/images/icons/hashtag.svg new file mode 100644 index 0000000..7959a5c --- /dev/null +++ b/assets/images/icons/hashtag.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/icons/menu-outline.svg b/assets/images/icons/menu-outline.svg new file mode 100644 index 0000000..1a0134a --- /dev/null +++ b/assets/images/icons/menu-outline.svg @@ -0,0 +1 @@ +Menu \ No newline at end of file diff --git a/assets/images/icons/nft.svg b/assets/images/icons/nft.svg new file mode 100644 index 0000000..2ec2fd4 --- /dev/null +++ b/assets/images/icons/nft.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/images/icons/online-users.svg b/assets/images/icons/online-users.svg new file mode 100644 index 0000000..3740ee4 --- /dev/null +++ b/assets/images/icons/online-users.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/images/icons/reload-outline.svg b/assets/images/icons/reload-outline.svg new file mode 100644 index 0000000..2244003 --- /dev/null +++ b/assets/images/icons/reload-outline.svg @@ -0,0 +1 @@ +Reload \ No newline at end of file diff --git a/assets/images/icons/send.svg b/assets/images/icons/send.svg new file mode 100644 index 0000000..76eedb7 --- /dev/null +++ b/assets/images/icons/send.svg @@ -0,0 +1 @@ +Send \ No newline at end of file diff --git a/assets/images/icons/star.svg b/assets/images/icons/star.svg new file mode 100644 index 0000000..cef6bac --- /dev/null +++ b/assets/images/icons/star.svg @@ -0,0 +1 @@ +Star \ No newline at end of file diff --git a/assets/images/icons/time.svg b/assets/images/icons/time.svg new file mode 100644 index 0000000..1b15c8a --- /dev/null +++ b/assets/images/icons/time.svg @@ -0,0 +1 @@ +Time \ No newline at end of file diff --git a/assets/images/icons/user.svg b/assets/images/icons/user.svg new file mode 100644 index 0000000..6ec4d5b --- /dev/null +++ b/assets/images/icons/user.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/logo-large-black.svg b/assets/images/logo-large-black.svg new file mode 100644 index 0000000..f28dad5 --- /dev/null +++ b/assets/images/logo-large-black.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/logo-large.svg b/assets/images/logo-large.svg new file mode 100644 index 0000000..e2d43af --- /dev/null +++ b/assets/images/logo-large.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/logo-white.svg b/assets/images/logo-white.svg new file mode 100644 index 0000000..19682d7 --- /dev/null +++ b/assets/images/logo-white.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/images/logo.svg b/assets/images/logo.svg new file mode 100644 index 0000000..f12a370 --- /dev/null +++ b/assets/images/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/mobile.png b/assets/images/mobile.png new file mode 100644 index 0000000..fd79918 Binary files /dev/null and b/assets/images/mobile.png differ diff --git a/assets/images/new-event-bg.png b/assets/images/new-event-bg.png new file mode 100644 index 0000000..b742d60 Binary files /dev/null and b/assets/images/new-event-bg.png differ diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..b1bcd54 --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,472 @@ +// 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` 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, Presence} from "phoenix" +import {LiveSocket} from "phoenix_live_view" +import topbar from "../vendor/topbar" +import Alpine from 'alpinejs' +import flatpickr from "flatpickr" +import moment from "moment-timezone" +import QRCodeStyling from "qr-code-styling" +import { Presenter } from "./presenter" +import { Manager } from "./manager" +window.moment = moment + + +window.moment.locale('fr', { + months : 'janvier_février_mars_avril_mai_juin_juillet_août_septembre_octobre_novembre_décembre'.split('_'), + monthsShort : 'janv._févr._mars_avr._mai_juin_juil._août_sept._oct._nov._déc.'.split('_'), + monthsParseExact : true, + weekdays : 'dimanche_lundi_mardi_mercredi_jeudi_vendredi_samedi'.split('_'), + weekdaysShort : 'dim._lun._mar._mer._jeu._ven._sam.'.split('_'), + weekdaysMin : 'Di_Lu_Ma_Me_Je_Ve_Sa'.split('_'), + weekdaysParseExact : true, + longDateFormat : { + LT : 'HH:mm', + LTS : 'HH:mm:ss', + L : 'DD/MM/YYYY', + LL : 'D MMMM YYYY', + LLL : 'D MMMM YYYY HH:mm', + LLLL : 'dddd D MMMM YYYY HH:mm' + }, + calendar : { + sameDay : '[Aujourd’hui à] LT', + nextDay : '[Demain à] LT', + nextWeek : 'dddd [à] LT', + lastDay : '[Hier à] LT', + lastWeek : 'dddd [dernier à] LT', + sameElse : 'L' + }, + relativeTime : { + future : 'dans %s', + past : 'il y a %s', + s : 'quelques secondes', + m : 'une minute', + mm : '%d minutes', + h : 'une heure', + hh : '%d heures', + d : 'un jour', + dd : '%d jours', + M : 'un mois', + MM : '%d mois', + y : 'un an', + yy : '%d ans' + }, + dayOfMonthOrdinalParse : /\d{1,2}(er|e)/, + ordinal : function (number) { + return number + (number === 1 ? 'er' : 'e'); + }, + meridiemParse : /PD|MD/, + isPM : function (input) { + return input.charAt(0) === 'M'; + }, + // In case the meridiem units are not separated around 12, then implement + // this function (look at locale/id.js for an example). + // meridiemHour : function (hour, meridiem) { + // return /* 0-23 hour, given meridiem token and hour 1-12 */ ; + // }, + meridiem : function (hours, minutes, isLower) { + return hours < 12 ? 'PD' : 'MD'; + }, + week : { + dow : 1, // Monday is the first day of the week. + doy : 4 // Used to determine first week of the year. + } +}); +window.moment.locale(navigator.languages[0].split('-')[0]) + + +window.Alpine = Alpine +Alpine.start() + +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +let Hooks = {} + +Hooks.Scroll = { + mounted() { + if (this.el.dataset.postsNb > 4) window.scrollTo({top: document.querySelector(this.el.dataset.target).scrollHeight, behavior: 'smooth'}); + this.handleEvent("scroll", () => { + let t = document.querySelector(this.el.dataset.target) + if (this.el.childElementCount > 4 && (window.scrollY + window.innerHeight >= t.offsetHeight - 100)) { + window.scrollTo({top: t.scrollHeight, behavior: 'smooth'}); + } + }) + } +} + +Hooks.ScrollIntoDiv = { + mounted() { + let t = document.querySelector(this.el.dataset.target) + if (this.el.dataset.postsNb > 4) t.scrollTo({top: t.scrollHeight, behavior: 'smooth'}); + + this.handleEvent("scroll", () => { + let t = document.querySelector(this.el.dataset.target); + if (this.el.childElementCount > 4 && (t.scrollHeight - t.scrollTop < t.clientHeight + 100)) { + t.scrollTo({top: t.scrollHeight, behavior: 'smooth'}); + } + }) + } +} + +Hooks.PostForm = { + onPress(e, submitBtn, TA) { + if (e.key == "Enter" && !e.shiftKey) { + e.preventDefault() + submitBtn.click() + } else { + if (TA.value.length > 2) { + submitBtn.classList.remove("opacity-50") + submitBtn.classList.add("opacity-100") + } else { + submitBtn.classList.add("opacity-50") + submitBtn.classList.remove("opacity-100") + } + } + }, + onSubmit(e, TA) { + e.preventDefault() + document.getElementById("hiddenSubmit").click() + TA.value = "" + }, + mounted() { + const submitBtn = document.getElementById("submitBtn") + const TA = document.getElementById("postFormTA") + submitBtn.addEventListener("click", (e) => this.onSubmit(e, TA)) + TA.addEventListener("keydown", (e) => this.onPress(e, submitBtn, TA)) + }, + updated() { + const submitBtn = document.getElementById("submitBtn") + const TA = document.getElementById("postFormTA") + if (TA.value.length > 2) { + submitBtn.classList.remove("opacity-50") + submitBtn.classList.add("opacity-100") + } else { + submitBtn.classList.add("opacity-50") + submitBtn.classList.remove("opacity-100") + } + }, + destroyed() { + const submitBtn = document.getElementById("submitBtn") + const TA = document.getElementById("postFormTA") + TA.removeEventListener("keydown", (e) => this.onPress(e, submitBtn, TA)) + submitBtn.removeEventListener("click", (e) => this.onSubmit(e, TA)) + } +} + +Hooks.CalendarLocalDate = { + mounted() { + this.el.innerHTML = moment.utc(this.el.dataset.date).local().calendar() + }, + updated() { + this.el.innerHTML = moment.utc(this.el.dataset.date).local().calendar() + } +} +Hooks.Pickr = { + mounted() { + const getDefaultDate = (dateStart, dateEnd, mode) => { + if (mode == "range") { + return moment.utc(dateStart).format('Y-MM-DD HH:mm') + " - " + moment.utc(dateEnd).format('Y-MM-DD HH:mm') + } else { + return moment.utc(dateStart).format('Y-MM-DD HH:mm') + } + }; + this.pickr = flatpickr(this.el, { + wrap: true, + inline: false, + enableTime: true, + enable: JSON.parse(this.el.dataset.enable), + time_24hr: true, + formatDate: (date, format, locale) => { + return moment(date).utc().format('Y-MM-DD HH:mm'); + }, + parseDate: (datestr, format) => { + return moment.utc(datestr).local().toDate(); + }, + locale: { + firstDayOfWeek: 1, + rangeSeparator: ' - ' + }, + mode: this.el.dataset.mode == "range" ? "range" : "single", + minuteIncrement: 1, + dateFormat: "Y-m-d H:i", + defaultDate: getDefaultDate(this.el.dataset.defaultDateStart, this.el.dataset.defaultDateEnd, this.el.dataset.mode) + }) + }, + updated() { + }, + destroyed() { + this.pickr.destroy() + } +} +Hooks.Presenter = { + mounted() { + this.presenter = new Presenter(this) + this.presenter.init() + } +} +Hooks.Manager = { + mounted() { + this.manager = new Manager(this) + this.manager.init() + }, + updated() { + this.manager.update() + } +} +Hooks.OpenPresenter = { + open(e) { + e.preventDefault() + window.open(this.el.dataset.url, 'newwindow', + 'width=' + window.screen.width + ',height=' + window.screen.height) + }, + mounted() { + this.el.addEventListener("click", e => this.open(e)) + }, + updated() { + this.el.removeEventListener("click", e => this.open(e)) + this.el.addEventListener("click", e => this.open(e)) + }, + destroyed() { + this.el.removeEventListener("click", e => this.open(e)) + } +} +Hooks.GlobalReacts = { + mounted() { + + this.handleEvent('global-react', data => { + var img = document.createElement("img"); + img.src = "/images/icons/" + data.type + ".svg" + img.className = "react-animation absolute transform opacity-0" + this.el.className + this.el.appendChild(img) + }) + this.handleEvent('reset-global-react', data => { + this.el.innerHTML = "" + }) + } +} +Hooks.JoinEvent = { + mounted() { + const loading = document.getElementById("loading") + const submit = document.getElementById("submit") + const input = document.getElementById("input") + + submit.addEventListener("click", (e) => { + if (input.value.length > 0) { + submit.style.display = "none" + loading.style.display = "block" + } + }) + }, + destroyed() { + const loading = document.getElementById("loading") + const submit = document.getElementById("submit") + const input = document.getElementById("input") + + submit.removeEventListener("click", (e) => { + if (input.value.length > 0) { + submit.style.display = "none" + loading.style.display = "block" + } + }) + } +} +Hooks.WelcomeEarly = { + mounted() { + + if (localStorage.getItem("welcome-early") !== "false") { + this.el.style.display = "block" + this.el.children[0].addEventListener("click", (e) => { + e.preventDefault() + localStorage.setItem("welcome-early", "false") + this.el.style.display = "none" + }) + } + + }, + destroyed() { + this.el.children[0].removeEventListener("click", (e) => { + e.preventDefault() + localStorage.setItem("welcome-early", "false") + this.el.style.display = "none" + }) + } +} +Hooks.DefaultValue = { + mounted() { + this.el.value = moment(this.el.dataset.defaultValue ? this.el.dataset.defaultValue : undefined).utc().format(); + } +} +Hooks.ClickFeedback = { + clicked(e) { + this.el.className = "animate__animated animate__rubberBand animate__faster"; + setTimeout(() => { + this.el.className = ""; + } , 500); + }, + mounted() { + this.el.addEventListener("click", (e) => this.clicked(e)) + }, + destroy() { + this.el.removeEventListener("click", (e) => this.clicked(e)) + } +} +Hooks.QRCode = { + draw() { + var url = this.el.dataset.code ? window.location.protocol + "//" + window.location.host + "/e/" + this.el.dataset.code : window.location.href; + this.el.style.width = document.documentElement.clientWidth * .27 + "px" + this.el.style.height = document.documentElement.clientWidth * .27 + "px" + + if (this.qrCode == null) { + this.qrCode = new QRCodeStyling({ + width: this.el.dataset.dynamic ? document.documentElement.clientWidth * .25 : 240, + height: this.el.dataset.dynamic ? document.documentElement.clientWidth * .25 : 240, + margin: 0, + image: + "/images/logo.png", + data: url, + cornersSquareOptions: { + type: "square" + }, + dotsOptions: { + type: "square", + gradient: { + type: "linear", + rotation: Math.PI * 0.2, + colorStops: [{ + offset: 0, + color: '#14bfdb' + }, { + offset: 1, + color: '#b80fef' + }]} + }, + imageOptions: { + crossOrigin: "anonymous", + imageSize: 0.6, + margin: 10 + } + }) + this.qrCode.append(this.el) + } else { + this.qrCode.update({ + width: this.el.dataset.dynamic ? document.documentElement.clientWidth * .25 : 240, + height: this.el.dataset.dynamic ? document.documentElement.clientWidth * .25 : 240 + }) + } + + }, + mounted() { + window.addEventListener("resize", this.draw.bind(this)); + this.draw() + if (this.el.dataset.getUrl) { + setTimeout(() => { + var dataURL = this.qrCode._canvas.toDataURL() + document.getElementById("qr-url").value = dataURL + }, 500); + } + }, + updated() { + }, + destroyed() { + } +} + +let Uploaders = {} + +Uploaders.S3 = function(entries, onViewError){ + entries.forEach(entry => { + let formData = new FormData() + let {url, fields} = entry.meta + Object.entries(fields).forEach(([key, val]) => formData.append(key, val)) + formData.append("file", entry.file) + let xhr = new XMLHttpRequest() + onViewError(() => xhr.abort()) + xhr.onload = () => xhr.status === 204 ? entry.progress(100) : entry.error() + xhr.onerror = () => entry.error() + xhr.upload.addEventListener("progress", (event) => { + if(event.lengthComputable){ + let percent = Math.round((event.loaded / event.total) * 100) + if(percent < 100){ entry.progress(percent) } + } + }) + + xhr.open("POST", url, true) + xhr.send(formData) + }) +} + + +let liveSocket = new LiveSocket("/live", Socket, { + uploaders: Uploaders, + params: {_csrf_token: csrfToken, tz: Intl.DateTimeFormat().resolvedOptions().timeZone}, + hooks: Hooks, + dom: { + onBeforeElUpdated(from, to){ + if(from._x_dataStack){ + window.Alpine.clone(from, to) + window.Alpine.initTree(to) + } + } + },}) + +// Show progress bar on live navigation and form submits +let topBarScheduled = undefined +topbar.config({barColors: {0: "#fff"}, shadowColor: "rgba(0, 0, 0, .3)"}) +window.addEventListener("phx:page-loading-start", info => { + if(!topBarScheduled) { + topBarScheduled = setTimeout(() => topbar.show(), 500) + } +}) +window.addEventListener("phx:page-loading-stop", info => { + clearTimeout(topBarScheduled) + topBarScheduled = undefined + topbar.hide() +}) + +const renderOnlineUsers = function(presences) { + let onlineUsers = Presence.list(presences, (_id, {metas: [user, ...rest]}) => { + return onlineUserTemplate(user); + }).join("") + + document.querySelector("body").innerHTML = onlineUsers; +} + +const onlineUserTemplate = function(user) { + return ` +
+ aaa +
+ ` +} + +let presences = {}; +liveSocket.on("presence_state", state => { + presences = Presence.syncState(presences, state) + renderOnlineUsers(presences) +}) + +// 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 \ No newline at end of file diff --git a/assets/js/manager.js b/assets/js/manager.js new file mode 100644 index 0000000..64bf23d --- /dev/null +++ b/assets/js/manager.js @@ -0,0 +1,78 @@ +import { tns } from "tiny-slider" + +export class Manager { + + constructor(context) { + this.context = context + this.currentPage = parseInt(context.el.dataset.currentPage) + this.maxPage = parseInt(context.el.dataset.maxPage) + } + + init() { + + this.context.handleEvent('page-manage', data => { + var el = document.getElementById("slide-preview-" + data.current_page) + + if (el) { + setTimeout(() => { + document.getElementById("slide-preview-" + data.current_page).scrollIntoView({ + block: 'center', + behavior: 'smooth' + }); + }, data.timeout ? data.timeout : 0) + } + }) + + window.addEventListener('keydown', (e) => { + + if (e.target.tagName.toLowerCase() != "input") { + e.preventDefault() + + switch (e.key) { + case 'ArrowUp': + this.prevPage() + break + case 'ArrowLeft': + this.prevPage() + break + case 'ArrowRight': + this.nextPage() + break + case 'ArrowDown': + this.nextPage() + break + } + } + }); + } + + update() { + this.currentPage = parseInt(this.context.el.dataset.currentPage) + var el = document.getElementById("slide-preview-" + this.currentPage) + + if (el) { + document.getElementById("slide-preview-" + this.currentPage).scrollIntoView({ + block: 'center', + behavior: 'smooth' + }); + } + } + + nextPage() { + if(this.currentPage == this.maxPage - 1) + return; + + this.currentPage += 1; + this.context.pushEventTo(this.context.el, "current-page", {"page": this.currentPage.toString()}); + } + + prevPage() { + if(this.currentPage == 0) + return; + + this.currentPage -= 1; + this.context.pushEventTo(this.context.el, "current-page", {"page": this.currentPage.toString()}); + + } + +} \ No newline at end of file diff --git a/assets/js/presenter.js b/assets/js/presenter.js new file mode 100644 index 0000000..cb60fcc --- /dev/null +++ b/assets/js/presenter.js @@ -0,0 +1,105 @@ +import { tns } from "tiny-slider" + +export class Presenter { + + constructor(context) { + this.context = context + this.currentPage = parseInt(context.el.dataset.currentPage) + this.maxPage = parseInt(context.el.dataset.maxPage) + this.hash = context.el.dataset.hash + } + + init() { + + this.slider = tns({ + container: '#slider', + items: 1, + mode: 'gallery', + slideBy: 'page', + center: true, + autoplay: false, + controls: false, + swipeAngle: false, + startIndex: this.currentPage, + loop: false, + nav: false + }); + + this.context.handleEvent('page', data => { + //set current page + this.currentPage = parseInt(data.current_page) + this.slider.goTo(data.current_page) + }) + + this.context.handleEvent('chat-visible', data => { + if (data.value) { + document.getElementById("post-list").classList.remove("animate__animated", "animate__fadeOutLeft") + document.getElementById("post-list").classList.add("animate__animated", "animate__fadeInLeft") + } else { + document.getElementById("post-list").classList.remove("animate__animated", "animate__fadeInLeft") + document.getElementById("post-list").classList.add("animate__animated", "animate__fadeOutLeft") + } + }) + + this.context.handleEvent('poll-visible', data => { + if (data.value) { + document.getElementById("poll").classList.remove("animate__animated", "animate__fadeOut") + document.getElementById("poll").classList.add("animate__animated", "animate__fadeIn") + } else { + document.getElementById("poll").classList.remove("animate__animated", "animate__fadeIn") + document.getElementById("poll").classList.add("animate__animated", "animate__fadeOut") + } + }) + + this.context.handleEvent('join-screen-visible', data => { + if (data.value) { + document.getElementById("joinScreen").classList.remove("animate__animated", "animate__fadeOut") + document.getElementById("joinScreen").classList.add("animate__animated", "animate__fadeIn") + } else { + document.getElementById("joinScreen").classList.remove("animate__animated", "animate__fadeIn") + document.getElementById("joinScreen").classList.add("animate__animated", "animate__fadeOut") + } + }) + + window.addEventListener('keyup', (e) => { + if (e.target.tagName.toLowerCase() != "input") { + e.preventDefault() + + switch (e.key) { + case 'f': // F + this.fullscreen() + break + } + } + }); + } + + fullscreen() { + + var docEl = document.getElementById("presenter") + + try { + docEl.webkitRequestFullscreen() + .then(function() { + }) + .catch(function(error) { + + }); + } catch (e) { + docEl.requestFullscreen() + .then(function() { + }) + .catch(function(error) { + }); + + docEl.mozRequestFullScreen() + .then(function() { + }) + .catch(function(error) { + }); + } + + + } + +} \ No newline at end of file diff --git a/assets/package.json b/assets/package.json new file mode 100644 index 0000000..d6fcec6 --- /dev/null +++ b/assets/package.json @@ -0,0 +1,25 @@ +{ + "scripts": { + "deploy": "NODE_ENV=production tailwindcss --postcss --minify --input=css/app.css --output=../priv/static/assets/app.css" + }, + "devDependencies": { + "alpinejs": "^3.4.2", + "autoprefixer": "^10.3.7", + "cpx": "^1.5.0", + "esbuild": "^0.14.14", + "flatpickr": "^4.6.9", + "postcss": "^8.3.9", + "postcss-import": "^14.0.2", + "tailwindcss": "^2.2.16" + }, + "dependencies": { + "animate.css": "^4.1.1", + "moment": "^2.29.1", + "moment-timezone": "^0.5.34", + "phoenix": "file:../deps/phoenix", + "phoenix_html": "file:../deps/phoenix_html", + "phoenix_live_view": "file:../deps/phoenix_live_view", + "qr-code-styling": "^1.6.0-rc.1", + "tiny-slider": "^2.9.4" + } +} diff --git a/assets/postcss.config.js b/assets/postcss.config.js new file mode 100644 index 0000000..f6c9294 --- /dev/null +++ b/assets/postcss.config.js @@ -0,0 +1,7 @@ +module.exports = { + plugins: { + "postcss-import": {}, + tailwindcss: {}, + autoprefixer: {}, + } +} \ No newline at end of file diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js new file mode 100644 index 0000000..53a0b57 --- /dev/null +++ b/assets/tailwind.config.js @@ -0,0 +1,135 @@ +const { colors: defaultColors } = require('tailwindcss/defaultTheme') + +const colors = { + ...defaultColors, + ...{ + "water-blue": { + "50": "#E3F2FD", + "100": "#C2E3FA", + "200": "#84C8F6", + "300": "#3DA7F0", + "400": "#1395EC", + "500": "#1186D5", + "600": "#0D65A1", + "700": "#0A5689", + "800": "#0A4B76", + "900": "#073250", + }, + "electric-purple": { + "50": "#F2E0FF", + "100": "#E3BDFF", + "200": "#C77AFF", + "300": "#A62EFF", + "400": "#9200FF", + "500": "#A327FF", + "600": "#6400AD", + "700": "#550094", + "800": "#490080", + "900": "#320057", + }, + "wedgewood": { + "50": "#F0F4F8", + "100": "#D9E3ED", + "200": "#B9CCDF", + "300": "#97B3CE", + "400": "#7499BE", + "500": "#507DAA", + "600": "#3F6388", + "700": "#314D68", + "800": "#253B50", + "900": "#1A2938", + }, + "rose-madder": { + "50": "#FCEDEE", + "100": "#F9D5D7", + "200": "#F3ABB0", + "300": "#ED8188", + "400": "#E75761", + "500": "#E12D39", + "600": "#B4242E", + "700": "#871B22", + "800": "#5A1217", + "900": "#2D090B", + }, + "school-bus-yellow": { + "50": "#FFFBEB", + "100": "#FEF3C7", + "200": "#FDE68A", + "300": "#FCD34D", + "400": "#FBBF24", + "500": "#F59E0B", + "600": "#D97706", + "700": "#B45309", + "800": "#92400E", + "900": "#78350F", + }, + "green-teal": { + "50": "#ECFDF5", + "100": "#D1FAE5", + "200": "#A7F3D0", + "300": "#6EE7B7", + "400": "#34D399", + "500": "#10B981", + "600": "#059669", + "700": "#047857", + "800": "#065F46", + "900": "#064E3B", + }, + }, +} + +module.exports = { + mode: 'jit', + purge: { + content: [ + './js/**/*.js', + '../lib/*_web/**/*.*ex' + ], + safelist: [ + '-top-1.5', + 'top-1', + 'left-3', + 'top-6', + 'h-5', + 'left-2.5', + 'top-3', + 'h-7' + ] + }, + darkMode: false, // or 'media' or 'class' + theme: { + extend: { + backgroundSize: { + 'size-200': '200% 200%', + }, + backgroundPosition: { + 'pos-0': '0% 0%', + 'pos-100': '100% 100%', + }, + colors: { + primary: colors["water-blue"], + secondary: colors["electric-purple"], + neutral: colors["wedgewood"], + "supporting-red": colors["rose-madder"], + "supporting-yellow": colors["school-bus-yellow"], + "supporting-green": colors["green-teal"] + } + }, + fontFamily: { + sans: ['Roboto', 'sans-serif'], + serif: ['Merriweather', 'serif'], + }, + boxShadow: { + "base": "0px 1px 3px 0px rgba(0,0,0,0.1), 0px 1px 2px 0px rgba(0,0,0,0.06)", + "lg": "0px 4px 6px 0px rgba(0,0,0,0.05), 0px 10px 15px 0px rgba(0,0,0,0.1)", + "md": "0px 4px 6px 0px rgba(0,0,0,0.1), 0px 2px 4px 0px rgba(0,0,0,0.06)", + "xl": "0px 10px 10px 0px rgba(0,0,0,0.04), 0px 20px 25px 0px rgba(0,0,0,0.1)", + "2xl": "0px 25px 50px 0px rgba(0,0,0,0.25)", + "inner": "inset 0px 2px 4px 0px rgba(0,0,0,0.06)" + } + }, + variants: { + extend: {}, + }, + plugins: [], +} diff --git a/assets/vendor/topbar.js b/assets/vendor/topbar.js new file mode 100644 index 0000000..ff7fbb6 --- /dev/null +++ b/assets/vendor/topbar.js @@ -0,0 +1,157 @@ +/** + * @license MIT + * topbar 1.0.0, 2021-01-06 + * http://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, + progressTimerId, + fadeTimerId, + currentProgress, + showing, + 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 () { + if (showing) return; + 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 () { + 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)); diff --git a/assets/yarn.lock b/assets/yarn.lock new file mode 100644 index 0000000..53eaf1d --- /dev/null +++ b/assets/yarn.lock @@ -0,0 +1,2123 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/code-frame@^7.0.0": + version "7.16.7" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.16.7.tgz#44416b6bd7624b998f5b1af5d470856c40138789" + integrity sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg== + dependencies: + "@babel/highlight" "^7.16.7" + +"@babel/helper-validator-identifier@^7.16.7": + version "7.16.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz#e8c602438c4a8195751243da9031d1607d247cad" + integrity sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw== + +"@babel/highlight@^7.16.7": + version "7.16.10" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.16.10.tgz#744f2eb81579d6eea753c227b0f570ad785aba88" + integrity sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw== + dependencies: + "@babel/helper-validator-identifier" "^7.16.7" + chalk "^2.0.0" + js-tokens "^4.0.0" + +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.3": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@types/parse-json@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" + integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== + +"@vue/reactivity@~3.1.1": + version "3.1.5" + resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.1.5.tgz#dbec4d9557f7c8f25c2635db1e23a78a729eb991" + integrity sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg== + dependencies: + "@vue/shared" "3.1.5" + +"@vue/shared@3.1.5": + version "3.1.5" + resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.1.5.tgz#74ee3aad995d0a3996a6bb9533d4d280514ede03" + integrity sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA== + +acorn-node@^1.6.1: + version "1.8.2" + resolved "https://registry.yarnpkg.com/acorn-node/-/acorn-node-1.8.2.tgz#114c95d64539e53dede23de8b9d96df7c7ae2af8" + integrity sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A== + dependencies: + acorn "^7.0.0" + acorn-walk "^7.0.0" + xtend "^4.0.2" + +acorn-walk@^7.0.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" + integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== + +acorn@^7.0.0: + version "7.4.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" + integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== + +alpinejs@^3.4.2: + version "3.9.2" + resolved "https://registry.yarnpkg.com/alpinejs/-/alpinejs-3.9.2.tgz#d8b191a5f9d695400b99a0343b9743d9960bc58c" + integrity sha512-RoqGiEbOCCYr3yxhobJiuTsiD2NfnIe3dzbP/ll2hlSti675nP07n8994fw7CWq5p0xVpbRfAIyt2EjCzZhM6g== + dependencies: + "@vue/reactivity" "~3.1.1" + +animate.css@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/animate.css/-/animate.css-4.1.1.tgz#614ec5a81131d7e4dc362a58143f7406abd68075" + integrity sha512-+mRmCTv6SbCmtYJCN4faJMNFVNN5EuCTTprDTAo7YzIGji2KADmakjVA3+8mVDkZ2Bf09vayB35lSQIex2+QaQ== + +ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +anymatch@^1.3.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.2.tgz#553dcb8f91e3c889845dfdba34c77721b90b9d7a" + integrity sha512-0XNayC8lTHQ2OI8aljNCN3sSx6hsr/1+rlcDAotXJR7C1oZZHCNsfpbKwMjRA3Uqb5tF1Rae2oloTr4xpq+WjA== + dependencies: + micromatch "^2.1.5" + normalize-path "^2.0.0" + +anymatch@~3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" + integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +arg@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.1.tgz#eb0c9a8f77786cad2af8ff2b862899842d7b6adb" + integrity sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA== + +arr-diff@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf" + integrity sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8= + dependencies: + arr-flatten "^1.0.1" + +arr-diff@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" + integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA= + +arr-flatten@^1.0.1, arr-flatten@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" + integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg== + +arr-union@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" + integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ= + +array-unique@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53" + integrity sha1-odl8yvy8JiXMcPrc6zalDFiwGlM= + +array-unique@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" + integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= + +assign-symbols@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" + integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= + +async-each@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf" + integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ== + +atob@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" + integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== + +autoprefixer@^10.3.7: + version "10.4.4" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.4.tgz#3e85a245b32da876a893d3ac2ea19f01e7ea5a1e" + integrity sha512-Tm8JxsB286VweiZ5F0anmbyGiNI3v3wGv3mz9W+cxEDYB/6jbnj6GM9H9mK3wIL8ftgl+C07Lcwb8PG5PCCPzA== + dependencies: + browserslist "^4.20.2" + caniuse-lite "^1.0.30001317" + fraction.js "^4.2.0" + normalize-range "^0.1.2" + picocolors "^1.0.0" + postcss-value-parser "^4.2.0" + +babel-runtime@^6.9.2: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" + integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4= + dependencies: + core-js "^2.4.0" + regenerator-runtime "^0.11.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +base@^0.11.1: + version "0.11.2" + resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" + integrity sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg== + dependencies: + cache-base "^1.0.1" + class-utils "^0.3.5" + component-emitter "^1.2.1" + define-property "^1.0.0" + isobject "^3.0.1" + mixin-deep "^1.2.0" + pascalcase "^0.1.1" + +binary-extensions@^1.0.0: + version "1.13.1" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" + integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw== + +binary-extensions@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" + integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== + +bindings@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" + integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== + dependencies: + file-uri-to-path "1.0.0" + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +braces@^1.8.2: + version "1.8.5" + resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7" + integrity sha1-uneWLhLf+WnWt2cR6RS3N4V79qc= + dependencies: + expand-range "^1.8.1" + preserve "^0.2.0" + repeat-element "^1.1.2" + +braces@^2.3.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" + integrity sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w== + dependencies: + arr-flatten "^1.1.0" + array-unique "^0.3.2" + extend-shallow "^2.0.1" + fill-range "^4.0.0" + isobject "^3.0.1" + repeat-element "^1.1.2" + snapdragon "^0.8.1" + snapdragon-node "^2.0.1" + split-string "^3.0.2" + to-regex "^3.0.1" + +braces@^3.0.1, braces@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" + integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + dependencies: + fill-range "^7.0.1" + +browserslist@^4.20.2: + version "4.20.2" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.20.2.tgz#567b41508757ecd904dab4d1c646c612cd3d4f88" + integrity sha512-CQOBCqp/9pDvDbx3xfMi+86pr4KXIf2FDkTTdeuYw8OxS9t898LA1Khq57gtufFILXpfgsSx5woNgsBgvGjpsA== + dependencies: + caniuse-lite "^1.0.30001317" + electron-to-chromium "^1.4.84" + escalade "^3.1.1" + node-releases "^2.0.2" + picocolors "^1.0.0" + +bytes@^3.0.0: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +cache-base@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" + integrity sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ== + dependencies: + collection-visit "^1.0.0" + component-emitter "^1.2.1" + get-value "^2.0.6" + has-value "^1.0.0" + isobject "^3.0.1" + set-value "^2.0.0" + to-object-path "^0.3.0" + union-value "^1.0.0" + unset-value "^1.0.0" + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +camelcase-css@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5" + integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== + +caniuse-lite@^1.0.30001317: + version "1.0.30001319" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001319.tgz#eb4da4eb3ecdd409f7ba1907820061d56096e88f" + integrity sha512-xjlIAFHucBRSMUo1kb5D4LYgcN1M45qdKP++lhqowDpwJwGkpIRTt5qQqnhxjj1vHcI7nrJxWhCC1ATrCEBTcw== + +chalk@^2.0.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +chalk@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chokidar@^1.6.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.7.0.tgz#798e689778151c8076b4b360e5edd28cda2bb468" + integrity sha1-eY5ol3gVHIB2tLNg5e3SjNortGg= + dependencies: + anymatch "^1.3.0" + async-each "^1.0.0" + glob-parent "^2.0.0" + inherits "^2.0.1" + is-binary-path "^1.0.0" + is-glob "^2.0.0" + path-is-absolute "^1.0.0" + readdirp "^2.0.0" + optionalDependencies: + fsevents "^1.0.0" + +chokidar@^3.5.2: + version "3.5.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" + integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +class-utils@^0.3.5: + version "0.3.6" + resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" + integrity sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg== + dependencies: + arr-union "^3.1.0" + define-property "^0.2.5" + isobject "^3.0.0" + static-extend "^0.1.1" + +collection-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" + integrity sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA= + dependencies: + map-visit "^1.0.0" + object-visit "^1.0.0" + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= + +color-name@^1.0.0, color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +color-string@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.0.tgz#63b6ebd1bec11999d1df3a79a7569451ac2be8aa" + integrity sha512-9Mrz2AQLefkH1UvASKj6v6hj/7eWgjnT/cVsR8CumieLoT+g900exWeNogqtweI8dxloXN9BDQTYro1oWu/5CQ== + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + +color@^4.0.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/color/-/color-4.2.1.tgz#498aee5fce7fc982606c8875cab080ac0547c884" + integrity sha512-MFJr0uY4RvTQUKvPq7dh9grVOTYSFeXja2mBXioCGjnjJoXrAp9jJ1NQTDR73c9nwBSAQiNKloKl5zq9WB9UPw== + dependencies: + color-convert "^2.0.1" + color-string "^1.9.0" + +commander@^8.0.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" + integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== + +component-emitter@^1.2.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" + integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= + +copy-descriptor@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" + integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= + +core-js@^2.4.0: + version "2.6.12" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" + integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== + +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + +cosmiconfig@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.0.1.tgz#714d756522cace867867ccb4474c5d01bbae5d6d" + integrity sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ== + dependencies: + "@types/parse-json" "^4.0.0" + import-fresh "^3.2.1" + parse-json "^5.0.0" + path-type "^4.0.0" + yaml "^1.10.0" + +cpx@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/cpx/-/cpx-1.5.0.tgz#185be018511d87270dedccc293171e37655ab88f" + integrity sha1-GFvgGFEdhycN7czCkxceN2VauI8= + dependencies: + babel-runtime "^6.9.2" + chokidar "^1.6.0" + duplexer "^0.1.1" + glob "^7.0.5" + glob2base "^0.0.12" + minimatch "^3.0.2" + mkdirp "^0.5.1" + resolve "^1.1.7" + safe-buffer "^5.0.1" + shell-quote "^1.6.1" + subarg "^1.0.0" + +css-color-names@^0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0" + integrity sha1-gIrcLnnPhHOAabZGyyDsJ762KeA= + +css-unit-converter@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/css-unit-converter/-/css-unit-converter-1.1.2.tgz#4c77f5a1954e6dbff60695ecb214e3270436ab21" + integrity sha512-IiJwMC8rdZE0+xiEZHeru6YoONC4rfPMqGm2W85jMIbkFvv5nFTwJVFHam2eFrN6txmoUYFAFXiv8ICVeTO0MA== + +cssesc@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" + integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== + +debug@^2.2.0, debug@^2.3.3: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +decode-uri-component@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" + integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= + +define-property@^0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116" + integrity sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY= + dependencies: + is-descriptor "^0.1.0" + +define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6" + integrity sha1-dp66rz9KY6rTr56NMEybvnm/sOY= + dependencies: + is-descriptor "^1.0.0" + +define-property@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d" + integrity sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ== + dependencies: + is-descriptor "^1.0.2" + isobject "^3.0.1" + +defined@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693" + integrity sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM= + +detective@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/detective/-/detective-5.2.0.tgz#feb2a77e85b904ecdea459ad897cc90a99bd2a7b" + integrity sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg== + dependencies: + acorn-node "^1.6.1" + defined "^1.0.0" + minimist "^1.1.1" + +didyoumean@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037" + integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw== + +dlv@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79" + integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA== + +duplexer@^0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" + integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg== + +electron-to-chromium@^1.4.84: + version "1.4.91" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.91.tgz#842bbc97fd639abe7e46e7da530e3af5f6ca2831" + integrity sha512-Z7Jkc4+ouEg8F6RrrgLOs0kkJjI0cnyFQmnGVpln8pPifuKBNbUr37GMgJsCTSwy6Z9TK7oTwW33Oe+3aERYew== + +error-ex@^1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" + integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== + dependencies: + is-arrayish "^0.2.1" + +esbuild-android-64@0.14.27: + version "0.14.27" + resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.27.tgz#b868bbd9955a92309c69df628d8dd1945478b45c" + integrity sha512-LuEd4uPuj/16Y8j6kqy3Z2E9vNY9logfq8Tq+oTE2PZVuNs3M1kj5Qd4O95ee66yDGb3isaOCV7sOLDwtMfGaQ== + +esbuild-android-arm64@0.14.27: + version "0.14.27" + resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.27.tgz#e7d6430555e8e9c505fd87266bbc709f25f1825c" + integrity sha512-E8Ktwwa6vX8q7QeJmg8yepBYXaee50OdQS3BFtEHKrzbV45H4foMOeEE7uqdjGQZFBap5VAqo7pvjlyA92wznQ== + +esbuild-darwin-64@0.14.27: + version "0.14.27" + resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.27.tgz#4dc7484127564e89b4445c0a560a3cb50b3d68e1" + integrity sha512-czw/kXl/1ZdenPWfw9jDc5iuIYxqUxgQ/Q+hRd4/3udyGGVI31r29LCViN2bAJgGvQkqyLGVcG03PJPEXQ5i2g== + +esbuild-darwin-arm64@0.14.27: + version "0.14.27" + resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.27.tgz#469e59c665f84a8ed323166624c5e7b9b2d22ac1" + integrity sha512-BEsv2U2U4o672oV8+xpXNxN9bgqRCtddQC6WBh4YhXKDcSZcdNh7+6nS+DM2vu7qWIWNA4JbRG24LUUYXysimQ== + +esbuild-freebsd-64@0.14.27: + version "0.14.27" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.27.tgz#895df03bf5f87094a56c9a5815bf92e591903d70" + integrity sha512-7FeiFPGBo+ga+kOkDxtPmdPZdayrSzsV9pmfHxcyLKxu+3oTcajeZlOO1y9HW+t5aFZPiv7czOHM4KNd0tNwCA== + +esbuild-freebsd-arm64@0.14.27: + version "0.14.27" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.27.tgz#0b72a41a6b8655e9a8c5608f2ec1afdcf6958441" + integrity sha512-8CK3++foRZJluOWXpllG5zwAVlxtv36NpHfsbWS7TYlD8S+QruXltKlXToc/5ZNzBK++l6rvRKELu/puCLc7jA== + +esbuild-linux-32@0.14.27: + version "0.14.27" + resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.27.tgz#43b8ba3803b0bbe7f051869c6a8bf6de1e95de28" + integrity sha512-qhNYIcT+EsYSBClZ5QhLzFzV5iVsP1YsITqblSaztr3+ZJUI+GoK8aXHyzKd7/CKKuK93cxEMJPpfi1dfsOfdw== + +esbuild-linux-64@0.14.27: + version "0.14.27" + resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.27.tgz#dc8072097327ecfadba1735562824ce8c05dd0bd" + integrity sha512-ESjck9+EsHoTaKWlFKJpPZRN26uiav5gkI16RuI8WBxUdLrrAlYuYSndxxKgEn1csd968BX/8yQZATYf/9+/qg== + +esbuild-linux-arm64@0.14.27: + version "0.14.27" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.27.tgz#c52b58cbe948426b1559910f521b0a3f396f10b8" + integrity sha512-no6Mi17eV2tHlJnqBHRLekpZ2/VYx+NfGxKcBE/2xOMYwctsanCaXxw4zapvNrGE9X38vefVXLz6YCF8b1EHiQ== + +esbuild-linux-arm@0.14.27: + version "0.14.27" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.27.tgz#df869dbd67d4ee3a04b3c7273b6bd2b233e78a18" + integrity sha512-JnnmgUBdqLQO9hoNZQqNHFWlNpSX82vzB3rYuCJMhtkuaWQEmQz6Lec1UIxJdC38ifEghNTBsF9bbe8dFilnCw== + +esbuild-linux-mips64le@0.14.27: + version "0.14.27" + resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.27.tgz#a2b646d9df368b01aa970a7b8968be6dd6b01d19" + integrity sha512-NolWP2uOvIJpbwpsDbwfeExZOY1bZNlWE/kVfkzLMsSgqeVcl5YMen/cedRe9mKnpfLli+i0uSp7N+fkKNU27A== + +esbuild-linux-ppc64le@0.14.27: + version "0.14.27" + resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.27.tgz#9a21af766a0292578a3009c7408b8509cac7cefd" + integrity sha512-/7dTjDvXMdRKmsSxKXeWyonuGgblnYDn0MI1xDC7J1VQXny8k1qgNp6VmrlsawwnsymSUUiThhkJsI+rx0taNA== + +esbuild-linux-riscv64@0.14.27: + version "0.14.27" + resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.27.tgz#344a27f91568056a5903ad5841b447e00e78d740" + integrity sha512-D+aFiUzOJG13RhrSmZgrcFaF4UUHpqj7XSKrIiCXIj1dkIkFqdrmqMSOtSs78dOtObWiOrFCDDzB24UyeEiNGg== + +esbuild-linux-s390x@0.14.27: + version "0.14.27" + resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.27.tgz#73a7309bd648a07ef58f069658f989a5096130db" + integrity sha512-CD/D4tj0U4UQjELkdNlZhQ8nDHU5rBn6NGp47Hiz0Y7/akAY5i0oGadhEIg0WCY/HYVXFb3CsSPPwaKcTOW3bg== + +esbuild-netbsd-64@0.14.27: + version "0.14.27" + resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.27.tgz#482a587cdbd18a6c264a05136596927deb46c30a" + integrity sha512-h3mAld69SrO1VoaMpYl3a5FNdGRE/Nqc+E8VtHOag4tyBwhCQXxtvDDOAKOUQexBGca0IuR6UayQ4ntSX5ij1Q== + +esbuild-openbsd-64@0.14.27: + version "0.14.27" + resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.27.tgz#e99f8cdc63f1628747b63edd124d53cf7796468d" + integrity sha512-xwSje6qIZaDHXWoPpIgvL+7fC6WeubHHv18tusLYMwL+Z6bEa4Pbfs5IWDtQdHkArtfxEkIZz77944z8MgDxGw== + +esbuild-sunos-64@0.14.27: + version "0.14.27" + resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.27.tgz#8611d825bcb8239c78d57452e83253a71942f45c" + integrity sha512-/nBVpWIDjYiyMhuqIqbXXsxBc58cBVH9uztAOIfWShStxq9BNBik92oPQPJ57nzWXRNKQUEFWr4Q98utDWz7jg== + +esbuild-windows-32@0.14.27: + version "0.14.27" + resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.27.tgz#c06374206d4d92dd31d4fda299b09f51a35e82f6" + integrity sha512-Q9/zEjhZJ4trtWhFWIZvS/7RUzzi8rvkoaS9oiizkHTTKd8UxFwn/Mm2OywsAfYymgUYm8+y2b+BKTNEFxUekw== + +esbuild-windows-64@0.14.27: + version "0.14.27" + resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.27.tgz#756631c1d301dfc0d1a887deed2459ce4079582f" + integrity sha512-b3y3vTSl5aEhWHK66ngtiS/c6byLf6y/ZBvODH1YkBM+MGtVL6jN38FdHUsZasCz9gFwYs/lJMVY9u7GL6wfYg== + +esbuild-windows-arm64@0.14.27: + version "0.14.27" + resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.27.tgz#ad7e187193dcd18768b16065a950f4441d7173f4" + integrity sha512-I/reTxr6TFMcR5qbIkwRGvldMIaiBu2+MP0LlD7sOlNXrfqIl9uNjsuxFPGEG4IRomjfQ5q8WT+xlF/ySVkqKg== + +esbuild@^0.14.14: + version "0.14.27" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.27.tgz#41fe0f1b6b68b9f77cac025009bc54bb96e616f1" + integrity sha512-MZQt5SywZS3hA9fXnMhR22dv0oPGh6QtjJRIYbgL1AeqAoQZE+Qn5ppGYQAoHv/vq827flj4tIJ79Mrdiwk46Q== + optionalDependencies: + esbuild-android-64 "0.14.27" + esbuild-android-arm64 "0.14.27" + esbuild-darwin-64 "0.14.27" + esbuild-darwin-arm64 "0.14.27" + esbuild-freebsd-64 "0.14.27" + esbuild-freebsd-arm64 "0.14.27" + esbuild-linux-32 "0.14.27" + esbuild-linux-64 "0.14.27" + esbuild-linux-arm "0.14.27" + esbuild-linux-arm64 "0.14.27" + esbuild-linux-mips64le "0.14.27" + esbuild-linux-ppc64le "0.14.27" + esbuild-linux-riscv64 "0.14.27" + esbuild-linux-s390x "0.14.27" + esbuild-netbsd-64 "0.14.27" + esbuild-openbsd-64 "0.14.27" + esbuild-sunos-64 "0.14.27" + esbuild-windows-32 "0.14.27" + esbuild-windows-64 "0.14.27" + esbuild-windows-arm64 "0.14.27" + +escalade@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" + integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== + +escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= + +expand-brackets@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b" + integrity sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s= + dependencies: + is-posix-bracket "^0.1.0" + +expand-brackets@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622" + integrity sha1-t3c14xXOMPa27/D4OwQVGiJEliI= + dependencies: + debug "^2.3.3" + define-property "^0.2.5" + extend-shallow "^2.0.1" + posix-character-classes "^0.1.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +expand-range@^1.8.1: + version "1.8.2" + resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337" + integrity sha1-opnv/TNf4nIeuujiV+x5ZE/IUzc= + dependencies: + fill-range "^2.1.0" + +extend-shallow@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" + integrity sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8= + dependencies: + is-extendable "^0.1.0" + +extend-shallow@^3.0.0, extend-shallow@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8" + integrity sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg= + dependencies: + assign-symbols "^1.0.0" + is-extendable "^1.0.1" + +extglob@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1" + integrity sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE= + dependencies: + is-extglob "^1.0.0" + +extglob@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543" + integrity sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw== + dependencies: + array-unique "^0.3.2" + define-property "^1.0.0" + expand-brackets "^2.1.4" + extend-shallow "^2.0.1" + fragment-cache "^0.2.1" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +fast-glob@^3.2.7: + version "3.2.11" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.11.tgz#a1172ad95ceb8a16e20caa5c5e56480e5129c1d9" + integrity sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.4" + +fastq@^1.6.0: + version "1.13.0" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c" + integrity sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw== + dependencies: + reusify "^1.0.4" + +file-uri-to-path@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" + integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + +filename-regex@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26" + integrity sha1-wcS5vuPglyXdsQa3XB4wH+LxiyY= + +fill-range@^2.1.0: + version "2.2.4" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.4.tgz#eb1e773abb056dcd8df2bfdf6af59b8b3a936565" + integrity sha512-cnrcCbj01+j2gTG921VZPnHbjmdAf8oQV/iGeV2kZxGSyfYjjTyY79ErsK1WJWMpw6DaApEX72binqJE+/d+5Q== + dependencies: + is-number "^2.1.0" + isobject "^2.0.0" + randomatic "^3.0.0" + repeat-element "^1.1.2" + repeat-string "^1.5.2" + +fill-range@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" + integrity sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc= + dependencies: + extend-shallow "^2.0.1" + is-number "^3.0.0" + repeat-string "^1.6.1" + to-regex-range "^2.1.0" + +fill-range@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" + integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== + dependencies: + to-regex-range "^5.0.1" + +find-index@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/find-index/-/find-index-0.1.1.tgz#675d358b2ca3892d795a1ab47232f8b6e2e0dde4" + integrity sha1-Z101iyyjiS15Whq0cjL4tuLg3eQ= + +flatpickr@^4.6.9: + version "4.6.11" + resolved "https://registry.yarnpkg.com/flatpickr/-/flatpickr-4.6.11.tgz#c92f5108269c551c6b5069ecd754be610574574c" + integrity sha512-/rnbE/hu5I5zndLEyYfYvqE4vPDvI5At0lFcQA5eOPfjquZLcQ0HMKTL7rv5/+DvbPM3/vJcXpXjB/DjBh+1jw== + +for-in@^1.0.1, for-in@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" + integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= + +for-own@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce" + integrity sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4= + dependencies: + for-in "^1.0.1" + +fraction.js@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.2.0.tgz#448e5109a313a3527f5a3ab2119ec4cf0e0e2950" + integrity sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA== + +fragment-cache@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" + integrity sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk= + dependencies: + map-cache "^0.2.2" + +fs-extra@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.0.1.tgz#27de43b4320e833f6867cc044bfce29fdf0ef3b8" + integrity sha512-NbdoVMZso2Lsrn/QwLXOy6rm0ufY2zEOKCDzJR/0kBsb0E6qed0P3iYK+Ath3BfvXEeu4JhEtXLgILx5psUfag== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + +fsevents@^1.0.0: + version "1.2.13" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.13.tgz#f325cb0455592428bcf11b383370ef70e3bfcc38" + integrity sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw== + dependencies: + bindings "^1.5.0" + nan "^2.12.1" + +fsevents@~2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + +function-bind@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" + integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== + +get-value@^2.0.3, get-value@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" + integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= + +glob-base@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4" + integrity sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q= + dependencies: + glob-parent "^2.0.0" + is-glob "^2.0.0" + +glob-parent@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28" + integrity sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg= + dependencies: + is-glob "^2.0.0" + +glob-parent@^5.1.2, glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob-parent@^6.0.1: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +glob2base@^0.0.12: + version "0.0.12" + resolved "https://registry.yarnpkg.com/glob2base/-/glob2base-0.0.12.tgz#9d419b3e28f12e83a362164a277055922c9c0d56" + integrity sha1-nUGbPijxLoOjYhZKJ3BVkiycDVY= + dependencies: + find-index "^0.1.1" + +glob@^7.0.5, glob@^7.1.3, glob@^7.1.7: + version "7.2.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" + integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +graceful-fs@^4.1.11, graceful-fs@^4.1.6, graceful-fs@^4.2.0: + version "4.2.9" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.9.tgz#041b05df45755e587a24942279b9d113146e1c96" + integrity sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ== + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-value@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" + integrity sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8= + dependencies: + get-value "^2.0.3" + has-values "^0.1.4" + isobject "^2.0.0" + +has-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177" + integrity sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc= + dependencies: + get-value "^2.0.6" + has-values "^1.0.0" + isobject "^3.0.0" + +has-values@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771" + integrity sha1-bWHeldkd/Km5oCCJrThL/49it3E= + +has-values@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f" + integrity sha1-lbC2P+whRmGab+V/51Yo1aOe/k8= + dependencies: + is-number "^3.0.0" + kind-of "^4.0.0" + +has@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" + integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== + dependencies: + function-bind "^1.1.1" + +hex-color-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" + integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== + +hsl-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/hsl-regex/-/hsl-regex-1.0.0.tgz#d49330c789ed819e276a4c0d272dffa30b18fe6e" + integrity sha1-1JMwx4ntgZ4nakwNJy3/owsY/m4= + +hsla-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/hsla-regex/-/hsla-regex-1.0.0.tgz#c1ce7a3168c8c6614033a4b5f7877f3b225f9c38" + integrity sha1-wc56MWjIxmFAM6S194d/OyJfnDg= + +html-tags@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.1.0.tgz#7b5e6f7e665e9fb41f30007ed9e0d41e97fb2140" + integrity sha512-1qYz89hW3lFDEazhjW0yVAV87lw8lVkrJocr72XmBkMKsoSVJCQx3W8BXsC7hO2qAt8BoVjYjtAcZ9perqGnNg== + +import-fresh@^3.2.1: + version "3.3.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" + integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@^2.0.1, inherits@~2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +is-accessor-descriptor@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" + integrity sha1-qeEss66Nh2cn7u84Q/igiXtcmNY= + dependencies: + kind-of "^3.0.2" + +is-accessor-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656" + integrity sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ== + dependencies: + kind-of "^6.0.0" + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= + +is-arrayish@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" + integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== + +is-binary-path@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" + integrity sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg= + dependencies: + binary-extensions "^1.0.0" + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-buffer@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== + +is-color-stop@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-color-stop/-/is-color-stop-1.1.0.tgz#cfff471aee4dd5c9e158598fbe12967b5cdad345" + integrity sha1-z/9HGu5N1cnhWFmPvhKWe1za00U= + dependencies: + css-color-names "^0.0.4" + hex-color-regex "^1.1.0" + hsl-regex "^1.0.0" + hsla-regex "^1.0.0" + rgb-regex "^1.0.1" + rgba-regex "^1.0.0" + +is-core-module@^2.8.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.8.1.tgz#f59fdfca701d5879d0a6b100a40aa1560ce27211" + integrity sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA== + dependencies: + has "^1.0.3" + +is-data-descriptor@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" + integrity sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y= + dependencies: + kind-of "^3.0.2" + +is-data-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7" + integrity sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ== + dependencies: + kind-of "^6.0.0" + +is-descriptor@^0.1.0: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca" + integrity sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg== + dependencies: + is-accessor-descriptor "^0.1.6" + is-data-descriptor "^0.1.4" + kind-of "^5.0.0" + +is-descriptor@^1.0.0, is-descriptor@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec" + integrity sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg== + dependencies: + is-accessor-descriptor "^1.0.0" + is-data-descriptor "^1.0.0" + kind-of "^6.0.2" + +is-dotfile@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1" + integrity sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE= + +is-equal-shallow@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534" + integrity sha1-IjgJj8Ih3gvPpdnqxMRdY4qhxTQ= + dependencies: + is-primitive "^2.0.0" + +is-extendable@^0.1.0, is-extendable@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" + integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik= + +is-extendable@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" + integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA== + dependencies: + is-plain-object "^2.0.4" + +is-extglob@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0" + integrity sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA= + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= + +is-glob@^2.0.0, is-glob@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863" + integrity sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM= + dependencies: + is-extglob "^1.0.0" + +is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-number@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f" + integrity sha1-Afy7s5NGOlSPL0ZszhbezknbkI8= + dependencies: + kind-of "^3.0.2" + +is-number@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" + integrity sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU= + dependencies: + kind-of "^3.0.2" + +is-number@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff" + integrity sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ== + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-plain-object@^2.0.3, is-plain-object@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" + integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== + dependencies: + isobject "^3.0.1" + +is-posix-bracket@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4" + integrity sha1-MzTceXdDaOkvAW5vvAqI9c1ua8Q= + +is-primitive@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575" + integrity sha1-IHurkWOEmcB7Kt8kCkGochADRXU= + +is-windows@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" + integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== + +isarray@1.0.0, isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= + +isobject@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" + integrity sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk= + dependencies: + isarray "1.0.0" + +isobject@^3.0.0, isobject@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" + integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= + +js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +json-parse-even-better-errors@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + +kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" + integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ= + dependencies: + is-buffer "^1.1.5" + +kind-of@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57" + integrity sha1-IIE989cSkosgc3hpGkUGb65y3Vc= + dependencies: + is-buffer "^1.1.5" + +kind-of@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d" + integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw== + +kind-of@^6.0.0, kind-of@^6.0.2: + version "6.0.3" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" + integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== + +lilconfig@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.4.tgz#f4507d043d7058b380b6a8f5cb7bcd4b34cee082" + integrity sha512-bfTIN7lEsiooCocSISTWXkiWJkRqtL9wYtYy+8EK3Y41qh3mpwPU0ycTOgjdY9ErwXCc8QyrQp82bdL0Xkm9yA== + +lines-and-columns@^1.1.6: + version "1.2.4" + resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" + integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== + +lodash.topath@^4.5.2: + version "4.5.2" + resolved "https://registry.yarnpkg.com/lodash.topath/-/lodash.topath-4.5.2.tgz#3616351f3bba61994a0931989660bd03254fd009" + integrity sha1-NhY1Hzu6YZlKCTGYlmC9AyVP0Ak= + +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +map-cache@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" + integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8= + +map-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f" + integrity sha1-7Nyo8TFE5mDxtb1B8S80edmN+48= + dependencies: + object-visit "^1.0.0" + +math-random@^1.0.1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.4.tgz#5dd6943c938548267016d4e34f057583080c514c" + integrity sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A== + +merge2@^1.3.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +micromatch@^2.1.5: + version "2.3.11" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565" + integrity sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU= + dependencies: + arr-diff "^2.0.0" + array-unique "^0.2.1" + braces "^1.8.2" + expand-brackets "^0.1.4" + extglob "^0.3.1" + filename-regex "^2.0.0" + is-extglob "^1.0.0" + is-glob "^2.0.1" + kind-of "^3.0.2" + normalize-path "^2.0.1" + object.omit "^2.0.0" + parse-glob "^3.0.4" + regex-cache "^0.4.2" + +micromatch@^3.1.10: + version "3.1.10" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" + integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + braces "^2.3.1" + define-property "^2.0.2" + extend-shallow "^3.0.2" + extglob "^2.0.4" + fragment-cache "^0.2.1" + kind-of "^6.0.2" + nanomatch "^1.2.9" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.2" + +micromatch@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.4.tgz#896d519dfe9db25fce94ceb7a500919bf881ebf9" + integrity sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg== + dependencies: + braces "^3.0.1" + picomatch "^2.2.3" + +minimatch@^3.0.2, minimatch@^3.0.4: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimist@^1.1.0, minimist@^1.1.1, minimist@^1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== + +mixin-deep@^1.2.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" + integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA== + dependencies: + for-in "^1.0.2" + is-extendable "^1.0.1" + +mkdirp@^0.5.1: + version "0.5.6" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" + integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== + dependencies: + minimist "^1.2.6" + +modern-normalize@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/modern-normalize/-/modern-normalize-1.1.0.tgz#da8e80140d9221426bd4f725c6e11283d34f90b7" + integrity sha512-2lMlY1Yc1+CUy0gw4H95uNN7vjbpoED7NNRSBHE25nWfLBdmMzFCsPshlzbxHz+gYMcBEUN8V4pU16prcdPSgA== + +moment-timezone@^0.5.34: + version "0.5.34" + resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.34.tgz#a75938f7476b88f155d3504a9343f7519d9a405c" + integrity sha512-3zAEHh2hKUs3EXLESx/wsgw6IQdusOT8Bxm3D9UrHPQR7zlMmzwybC8zHEM1tQ4LJwP7fcxrWr8tuBg05fFCbg== + dependencies: + moment ">= 2.9.0" + +"moment@>= 2.9.0", moment@^2.29.1: + version "2.29.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" + integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= + +nan@^2.12.1: + version "2.15.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee" + integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ== + +nanoid@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.1.tgz#6347a18cac88af88f58af0b3594b723d5e99bb35" + integrity sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw== + +nanomatch@^1.2.9: + version "1.2.13" + resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" + integrity sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA== + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + define-property "^2.0.2" + extend-shallow "^3.0.2" + fragment-cache "^0.2.1" + is-windows "^1.0.2" + kind-of "^6.0.2" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +node-emoji@^1.11.0: + version "1.11.0" + resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.11.0.tgz#69a0150e6946e2f115e9d7ea4df7971e2628301c" + integrity sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A== + dependencies: + lodash "^4.17.21" + +node-releases@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.2.tgz#7139fe71e2f4f11b47d4d2986aaf8c48699e0c01" + integrity sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg== + +normalize-path@^2.0.0, normalize-path@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" + integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk= + dependencies: + remove-trailing-separator "^1.0.1" + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +normalize-range@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" + integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI= + +object-copy@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" + integrity sha1-fn2Fi3gb18mRpBupde04EnVOmYw= + dependencies: + copy-descriptor "^0.1.0" + define-property "^0.2.5" + kind-of "^3.0.3" + +object-hash@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5" + integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw== + +object-visit@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" + integrity sha1-95xEk68MU3e1n+OdOV5BBC3QRbs= + dependencies: + isobject "^3.0.0" + +object.omit@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa" + integrity sha1-Gpx0SCnznbuFjHbKNXmuKlTr0fo= + dependencies: + for-own "^0.1.4" + is-extendable "^0.1.1" + +object.pick@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" + integrity sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c= + dependencies: + isobject "^3.0.1" + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + dependencies: + wrappy "1" + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +parse-glob@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c" + integrity sha1-ssN2z7EfNVE7rdFz7wu246OIORw= + dependencies: + glob-base "^0.3.0" + is-dotfile "^1.0.0" + is-extglob "^1.0.0" + is-glob "^2.0.0" + +parse-json@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" + integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== + dependencies: + "@babel/code-frame" "^7.0.0" + error-ex "^1.3.1" + json-parse-even-better-errors "^2.3.0" + lines-and-columns "^1.1.6" + +pascalcase@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" + integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +path-type@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" + integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== + +"phoenix@file:../deps/phoenix": + version "1.6.6" + +"phoenix_html@file:../deps/phoenix_html": + version "3.2.0" + +"phoenix_live_view@file:../deps/phoenix_live_view": + version "0.17.7" + +picocolors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" + integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== + +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +pify@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" + integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw= + +posix-character-classes@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" + integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= + +postcss-import@^14.0.2: + version "14.1.0" + resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-14.1.0.tgz#a7333ffe32f0b8795303ee9e40215dac922781f0" + integrity sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw== + dependencies: + postcss-value-parser "^4.0.0" + read-cache "^1.0.0" + resolve "^1.1.7" + +postcss-js@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-3.0.3.tgz#2f0bd370a2e8599d45439f6970403b5873abda33" + integrity sha512-gWnoWQXKFw65Hk/mi2+WTQTHdPD5UJdDXZmX073EY/B3BWnYjO4F4t0VneTCnCGQ5E5GsCdMkzPaTXwl3r5dJw== + dependencies: + camelcase-css "^2.0.1" + postcss "^8.1.6" + +postcss-load-config@^3.1.0: + version "3.1.3" + resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-3.1.3.tgz#21935b2c43b9a86e6581a576ca7ee1bde2bd1d23" + integrity sha512-5EYgaM9auHGtO//ljHH+v/aC/TQ5LHXtL7bQajNAUBKUVKiYE8rYpFms7+V26D9FncaGe2zwCoPQsFKb5zF/Hw== + dependencies: + lilconfig "^2.0.4" + yaml "^1.10.2" + +postcss-nested@5.0.6: + version "5.0.6" + resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-5.0.6.tgz#466343f7fc8d3d46af3e7dba3fcd47d052a945bc" + integrity sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA== + dependencies: + postcss-selector-parser "^6.0.6" + +postcss-selector-parser@^6.0.6: + version "6.0.9" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.9.tgz#ee71c3b9ff63d9cd130838876c13a2ec1a992b2f" + integrity sha512-UO3SgnZOVTwu4kyLR22UQ1xZh086RyNZppb7lLAKBFK8a32ttG5i87Y/P3+2bRSjZNyJ1B7hfFNo273tKe9YxQ== + dependencies: + cssesc "^3.0.0" + util-deprecate "^1.0.2" + +postcss-value-parser@^3.3.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281" + integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ== + +postcss-value-parser@^4.0.0, postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" + integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== + +postcss@^8.1.6, postcss@^8.3.5, postcss@^8.3.9: + version "8.4.12" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.12.tgz#1e7de78733b28970fa4743f7da6f3763648b1905" + integrity sha512-lg6eITwYe9v6Hr5CncVbK70SoioNQIq81nsaG86ev5hAidQvmOeETBqs7jm43K2F5/Ley3ytDtriImV6TpNiSg== + dependencies: + nanoid "^3.3.1" + picocolors "^1.0.0" + source-map-js "^1.0.2" + +preserve@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" + integrity sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks= + +pretty-hrtime@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" + integrity sha1-t+PqQkNaTJsnWdmeDyAesZWALuE= + +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +purgecss@^4.0.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/purgecss/-/purgecss-4.1.3.tgz#683f6a133c8c4de7aa82fe2746d1393b214918f7" + integrity sha512-99cKy4s+VZoXnPxaoM23e5ABcP851nC2y2GROkkjS8eJaJtlciGavd7iYAw2V84WeBqggZ12l8ef44G99HmTaw== + dependencies: + commander "^8.0.0" + glob "^7.1.7" + postcss "^8.3.5" + postcss-selector-parser "^6.0.6" + +qr-code-styling@^1.6.0-rc.1: + version "1.6.0-rc.1" + resolved "https://registry.yarnpkg.com/qr-code-styling/-/qr-code-styling-1.6.0-rc.1.tgz#6c89e185fa50cc9135101085c12ae95b06f1b290" + integrity sha512-ModRIiW6oUnsP18QzrRYZSc/CFKFKIdj7pUs57AEVH20ajlglRpN3HukjHk0UbNMTlKGuaYl7Gt6/O5Gg2NU2Q== + dependencies: + qrcode-generator "^1.4.3" + +qrcode-generator@^1.4.3: + version "1.4.4" + resolved "https://registry.yarnpkg.com/qrcode-generator/-/qrcode-generator-1.4.4.tgz#63f771224854759329a99048806a53ed278740e7" + integrity sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw== + +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + +quick-lru@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" + integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== + +randomatic@^3.0.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.1.tgz#b776efc59375984e36c537b2f51a1f0aff0da1ed" + integrity sha512-TuDE5KxZ0J461RVjrJZCJc+J+zCkTb1MbH9AQUq68sMhOMcy9jLcb3BrZKgp9q9Ncltdg4QVqWrH02W2EFFVYw== + dependencies: + is-number "^4.0.0" + kind-of "^6.0.0" + math-random "^1.0.1" + +read-cache@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774" + integrity sha1-5mTvMRYRZsl1HNvo28+GtftY93Q= + dependencies: + pify "^2.3.0" + +readable-stream@^2.0.2: + version "2.3.7" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" + integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readdirp@^2.0.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525" + integrity sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ== + dependencies: + graceful-fs "^4.1.11" + micromatch "^3.1.10" + readable-stream "^2.0.2" + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +reduce-css-calc@^2.1.8: + version "2.1.8" + resolved "https://registry.yarnpkg.com/reduce-css-calc/-/reduce-css-calc-2.1.8.tgz#7ef8761a28d614980dc0c982f772c93f7a99de03" + integrity sha512-8liAVezDmUcH+tdzoEGrhfbGcP7nOV4NkGE3a74+qqvE7nt9i4sKLGBuZNOnpI4WiGksiNPklZxva80061QiPg== + dependencies: + css-unit-converter "^1.1.1" + postcss-value-parser "^3.3.0" + +regenerator-runtime@^0.11.0: + version "0.11.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" + integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== + +regex-cache@^0.4.2: + version "0.4.4" + resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.4.tgz#75bdc58a2a1496cec48a12835bc54c8d562336dd" + integrity sha512-nVIZwtCjkC9YgvWkpM55B5rBhBYRZhAaJbgcFYXXsHnbZ9UZI9nnVWYZpBlCqv9ho2eZryPnWrZGsOdPwVWXWQ== + dependencies: + is-equal-shallow "^0.1.3" + +regex-not@^1.0.0, regex-not@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" + integrity sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A== + dependencies: + extend-shallow "^3.0.2" + safe-regex "^1.1.0" + +remove-trailing-separator@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" + integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8= + +repeat-element@^1.1.2: + version "1.1.4" + resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.4.tgz#be681520847ab58c7568ac75fbfad28ed42d39e9" + integrity sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ== + +repeat-string@^1.5.2, repeat-string@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" + integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve-url@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" + integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= + +resolve@^1.1.7, resolve@^1.20.0: + version "1.22.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.0.tgz#5e0b8c67c15df57a89bdbabe603a002f21731198" + integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw== + dependencies: + is-core-module "^2.8.1" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +ret@~0.1.10: + version "0.1.15" + resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" + integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== + +reusify@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" + integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== + +rgb-regex@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/rgb-regex/-/rgb-regex-1.0.1.tgz#c0e0d6882df0e23be254a475e8edd41915feaeb1" + integrity sha1-wODWiC3w4jviVKR16O3UGRX+rrE= + +rgba-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/rgba-regex/-/rgba-regex-1.0.0.tgz#43374e2e2ca0968b0ef1523460b7d730ff22eeb3" + integrity sha1-QzdOLiyglosO8VI0YLfXMP8i7rM= + +rimraf@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + +safe-buffer@^5.0.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +safe-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" + integrity sha1-QKNmnzsHfR6UPURinhV91IAjvy4= + dependencies: + ret "~0.1.10" + +set-value@^2.0.0, set-value@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b" + integrity sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw== + dependencies: + extend-shallow "^2.0.1" + is-extendable "^0.1.1" + is-plain-object "^2.0.3" + split-string "^3.0.1" + +shell-quote@^1.6.1: + version "1.7.3" + resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.3.tgz#aa40edac170445b9a431e17bb62c0b881b9c4123" + integrity sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw== + +simple-swizzle@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" + integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo= + dependencies: + is-arrayish "^0.3.1" + +snapdragon-node@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" + integrity sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw== + dependencies: + define-property "^1.0.0" + isobject "^3.0.0" + snapdragon-util "^3.0.1" + +snapdragon-util@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2" + integrity sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ== + dependencies: + kind-of "^3.2.0" + +snapdragon@^0.8.1: + version "0.8.2" + resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d" + integrity sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg== + dependencies: + base "^0.11.1" + debug "^2.2.0" + define-property "^0.2.5" + extend-shallow "^2.0.1" + map-cache "^0.2.2" + source-map "^0.5.6" + source-map-resolve "^0.5.0" + use "^3.1.0" + +source-map-js@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" + integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== + +source-map-resolve@^0.5.0: + version "0.5.3" + resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a" + integrity sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw== + dependencies: + atob "^2.1.2" + decode-uri-component "^0.2.0" + resolve-url "^0.2.1" + source-map-url "^0.4.0" + urix "^0.1.0" + +source-map-url@^0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.1.tgz#0af66605a745a5a2f91cf1bbf8a7afbc283dec56" + integrity sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw== + +source-map@^0.5.6: + version "0.5.7" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" + integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= + +split-string@^3.0.1, split-string@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" + integrity sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw== + dependencies: + extend-shallow "^3.0.0" + +static-extend@^0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" + integrity sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY= + dependencies: + define-property "^0.2.5" + object-copy "^0.1.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +subarg@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/subarg/-/subarg-1.0.0.tgz#f62cf17581e996b48fc965699f54c06ae268b8d2" + integrity sha1-9izxdYHplrSPyWVpn1TAauJouNI= + dependencies: + minimist "^1.1.0" + +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +tailwindcss@^2.2.16: + version "2.2.19" + resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-2.2.19.tgz#540e464832cd462bb9649c1484b0a38315c2653c" + integrity sha512-6Ui7JSVtXadtTUo2NtkBBacobzWiQYVjYW0ZnKaP9S1ZCKQ0w7KVNz+YSDI/j7O7KCMHbOkz94ZMQhbT9pOqjw== + dependencies: + arg "^5.0.1" + bytes "^3.0.0" + chalk "^4.1.2" + chokidar "^3.5.2" + color "^4.0.1" + cosmiconfig "^7.0.1" + detective "^5.2.0" + didyoumean "^1.2.2" + dlv "^1.1.3" + fast-glob "^3.2.7" + fs-extra "^10.0.0" + glob-parent "^6.0.1" + html-tags "^3.1.0" + is-color-stop "^1.1.0" + is-glob "^4.0.1" + lodash "^4.17.21" + lodash.topath "^4.5.2" + modern-normalize "^1.1.0" + node-emoji "^1.11.0" + normalize-path "^3.0.0" + object-hash "^2.2.0" + postcss-js "^3.0.3" + postcss-load-config "^3.1.0" + postcss-nested "5.0.6" + postcss-selector-parser "^6.0.6" + postcss-value-parser "^4.1.0" + pretty-hrtime "^1.0.3" + purgecss "^4.0.3" + quick-lru "^5.1.1" + reduce-css-calc "^2.1.8" + resolve "^1.20.0" + tmp "^0.2.1" + +tiny-slider@^2.9.4: + version "2.9.4" + resolved "https://registry.yarnpkg.com/tiny-slider/-/tiny-slider-2.9.4.tgz#dd5cbf3065f1688ade8383ea6342aefcba22ccc4" + integrity sha512-LAs2kldWcY+BqCKw4kxd4CMx2RhWrHyEePEsymlOIISTlOVkjfK40sSD7ay73eKXBLg/UkluAZpcfCstimHXew== + +tmp@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" + integrity sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ== + dependencies: + rimraf "^3.0.0" + +to-object-path@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" + integrity sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68= + dependencies: + kind-of "^3.0.2" + +to-regex-range@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" + integrity sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg= + dependencies: + is-number "^3.0.0" + repeat-string "^1.6.1" + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +to-regex@^3.0.1, to-regex@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce" + integrity sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw== + dependencies: + define-property "^2.0.2" + extend-shallow "^3.0.2" + regex-not "^1.0.2" + safe-regex "^1.1.0" + +union-value@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847" + integrity sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg== + dependencies: + arr-union "^3.1.0" + get-value "^2.0.6" + is-extendable "^0.1.1" + set-value "^2.0.1" + +universalify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== + +unset-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559" + integrity sha1-g3aHP30jNRef+x5vw6jtDfyKtVk= + dependencies: + has-value "^0.3.1" + isobject "^3.0.0" + +urix@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" + integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= + +use@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" + integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== + +util-deprecate@^1.0.2, util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + +xtend@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + +yaml@^1.10.0, yaml@^1.10.2: + version "1.10.2" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" + integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..cfe3d61 --- /dev/null +++ b/build.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# exit on error +set -o errexit + +# Initial setup +mix deps.get --only prod +MIX_ENV=prod mix compile + +# Compile assets +mix assets.deploy + +# Build the release and overwrite the existing release directory +MIX_ENV=prod mix release --overwrite + +# for auto DB migration upon deploy +MIX_ENV=prod mix ecto.migrate \ No newline at end of file diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..22f7c9c --- /dev/null +++ b/config/config.exs @@ -0,0 +1,71 @@ +# 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 :claper, + ecto_repos: [Claper.Repo] + +# Configures the endpoint +config :claper, ClaperWeb.Endpoint, + url: [host: "localhost"], + render_errors: [view: ClaperWeb.ErrorView, accepts: ~w(html json), layout: false], + pubsub_server: Claper.PubSub, + live_view: [signing_salt: "DN0vwriJgVkHG0kn3hF5JKho/DE66onv"] + +config :claper, ClaperWeb.Gettext, + default_locale: "en", + locales: ~w(fr en) + +# 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 :claper, Claper.Mailer, adapter: Swoosh.Adapters.Local + +# Swoosh API client is needed for adapters other than SMTP. +config :swoosh, :api_client, false + +config :dart_sass, + version: "1.49.7", + default: [ + args: ~w(css/custom.scss ../priv/static/assets/custom.css), + cd: Path.expand("../assets", __DIR__) + ] + +# Configure esbuild (the version is required) +config :esbuild, + version: "0.12.18", + default: [ + 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 + +config :porcelain, driver: Porcelain.Driver.Basic + +config :ex_aws, + access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, :instance_role], + secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}, :instance_role], + region: {:system, "AWS_REGION"}, + normalize_path: false + +# 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" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..f3a8de3 --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,87 @@ +import Config + +# Configure your database +config :claper, Claper.Repo, + username: "claper", + password: "claper", + database: "postgres", + hostname: "localhost", + 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 use it +# with esbuild to bundle .js and .css sources. +config :claper, ClaperWeb.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: {0, 0, 0, 0}, port: 4000], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: "sm/ICUyPRRbxOU0s0NN/KFY8ze7XRALuvFLoyidy8l1ZBeWl8p/zFhfdI5II+Jdk", + watchers: [ + # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args) + esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, + sass: { + DartSass, + :install_and_run, + [:default, ~w(--embed-source-map --source-map-urls=absolute --watch)] + }, + npx: [ + "tailwindcss", + "--input=css/app.css", + "--output=../priv/static/assets/app.css", + "--postcss", + "--watch", + cd: Path.expand("../assets", __DIR__) + ] + ] + +# ## 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 +# +# Note that this task requires Erlang/OTP 20 or later. +# 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 :claper, ClaperWeb.Endpoint, + live_reload: [ + patterns: [ + ~r"priv/static/[^uploads].*(js|css|png|jpeg|jpg|gif|svg)$", + ~r"priv/gettext/.*(po)$", + ~r"lib/claper_web/(live|views)/.*(ex)$", + ~r"lib/claper_web/templates/.*(eex)$" + ] + ] + +# 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 diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..cabe8cf --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,65 @@ +import Config + +# For production, don't forget to configure the url host +# to something meaningful, Phoenix uses this information +# when generating URLs. +# +# Note we also include the path to a cache manifest +# containing the digested version of static files. This +# manifest is generated by the `mix phx.digest` task, +# which you should run after static files are built and +# before starting your production server. +config :claper, ClaperWeb.Endpoint, + cache_static_manifest: "priv/static/cache_manifest.json", + server: true + +# Do not print debug messages in production +config :logger, level: :info + +config :libcluster, + topologies: [ + default: [ + strategy: Cluster.Strategy.Kubernetes, + config: [ + mode: :dns, + kubernetes_node_basename: "claper", + kubernetes_selector: "app=claper", + kubernetes_namespace: "default", + polling_interval: 10_000 + ] + ] + ] + +# ## SSL Support +# +# To get SSL working, you will need to add the `https` key +# to the previous section and set your `:url` port to 443: +# +# config :claper, ClaperWeb.Endpoint, +# ..., +# url: [host: "example.com", port: 443], +# 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 endpoint, ensuring +# no data is ever sent via http, always redirecting to https: +# +# config :claper, ClaperWeb.Endpoint, +# force_ssl: [hsts: true] +# +# Check `Plug.SSL` for all available options in `force_ssl`. diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..6abba27 --- /dev/null +++ b/config/runtime.exs @@ -0,0 +1,86 @@ +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. +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 + """ + + config :claper, Claper.Repo, + url: database_url, + ssl: System.get_env("DB_SSL") == "true" || false, + ssl_opts: [ + verify: :verify_none + ], + prepare: :unnamed, + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), + queue_target: String.to_integer(System.get_env("QUEUE_TARGET") || "5000") + + # 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 + """ + + config :claper, ClaperWeb.Endpoint, + url: [ + host: System.get_env("ENDPOINT_HOST"), + port: 80 + ], + 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/plug_cowboy/Plug.Cowboy.html + # for details about using IPv6 vs IPv4 and loopback vs public addresses. + ip: {0, 0, 0, 0, 0, 0, 0, 0}, + port: String.to_integer(System.get_env("PORT") || "4000") + ], + secret_key_base: secret_key_base + + # ## Using releases + # + # If you are doing OTP releases, you need to instruct Phoenix + # to start each relevant endpoint: + # + # config :claper, ClaperWeb.Endpoint, server: true + # + # Then you can assemble a release by calling `mix release`. + # See `mix help release` for more information. + + # ## Configuring the mailer + # + # In production you need to configure the mailer to use a different adapter. + # Also, you may need to configure the Swoosh API client of your choice if you + # are not using SMTP. Here is an example of the configuration: + # + # config :claper, Claper.Mailer, + # adapter: Swoosh.Adapters.Mailgun, + # api_key: System.get_env("MAILGUN_API_KEY"), + # domain: System.get_env("MAILGUN_DOMAIN") + # + # For this example you need include a HTTP client required by Swoosh API client. + # Swoosh supports Hackney and Finch out of the box: + # + #config :claper, Claper.Mailer, + # adapter: Swoosh.Adapters.Postmark, + # api_key: System.get_env("SWOOSH_API_KEY") + + + config :swoosh, :api_client, Swoosh.ApiClient.Finch + # + # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. +end diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..0a47223 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,30 @@ +import Config + +# 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 :claper, Claper.Repo, + username: "claper", + password: "claper", + database: "claper_test#{System.get_env("MIX_TEST_PARTITION")}", + hostname: "localhost", + pool: Ecto.Adapters.SQL.Sandbox, + pool_size: 10 + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :claper, ClaperWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + secret_key_base: "YnJKcv692Yso3lHGqaJ6kJxKBDh0BUL+mJhguLm5rzoJ+xCEuN7MdrguMSnHKoz4", + server: false + +# In test we don't send emails. +config :claper, Claper.Mailer, adapter: Swoosh.Adapters.Test + +# Print only warnings and errors during test +config :logger, level: :warn + +# Initialize plugs at runtime for faster test compilation +config :phoenix, :plug_init_mode, :runtime diff --git a/elixir_buildpack.config b/elixir_buildpack.config new file mode 100644 index 0000000..63e1e63 --- /dev/null +++ b/elixir_buildpack.config @@ -0,0 +1,2 @@ +elixir_version=1.13.2 +erlang_version=24.0 diff --git a/lib/claper.ex b/lib/claper.ex new file mode 100644 index 0000000..9036bc2 --- /dev/null +++ b/lib/claper.ex @@ -0,0 +1,9 @@ +defmodule Claper do + @moduledoc """ + Claper 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 diff --git a/lib/claper/accounts.ex b/lib/claper/accounts.ex new file mode 100644 index 0000000..71f6525 --- /dev/null +++ b/lib/claper/accounts.ex @@ -0,0 +1,267 @@ +defmodule Claper.Accounts do + @moduledoc """ + The Accounts context. + """ + + import Ecto.Query, warn: false + alias Claper.Repo + + alias Claper.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 """ + 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(returning: [:uuid]) + 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) + 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) + 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, attrs) do + user + |> User.email_changeset(attrs) + |> 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.user_and_contexts_query(user, [context])) + end + + @doc """ + Delivers the update email instructions to the given user. + + ## Examples + + iex> deliver_update_email_instructions(user, current_email, &Routes.user_update_email_url(conn, :edit, &1)) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_magic_link(email, magic_link_url_fun) + when is_function(magic_link_url_fun, 1) do + {encoded_token, user_token} = UserToken.build_magic_token(email, "magic") + + Repo.insert!(user_token) + UserNotifier.deliver_magic_link(email, magic_link_url_fun.(encoded_token)) + end + + def deliver_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 + + ## 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 + + def magic_token_valid?(email) do + query = UserToken.user_magic_and_contexts_expiry_query(email) + Repo.exists?(query) + end + + @doc """ + Deletes the signed token with the given context. + """ + def delete_session_token(token) do + Repo.delete_all(UserToken.token_and_context_query(token, "session")) + :ok + end + + ## Confirmation + + @doc """ + Delivers the confirmation email instructions to the given user. + + ## Examples + + iex> deliver_user_confirmation_instructions(user, &Routes.user_confirmation_url(conn, :edit, &1)) + {:ok, %{to: ..., body: ...}} + + iex> deliver_user_confirmation_instructions(confirmed_user, &Routes.user_confirmation_url(conn, :edit, &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 + + defp confirm_user_multi(user) do + Ecto.Multi.new() + |> Ecto.Multi.update(:user, User.confirm_changeset(user)) + |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["confirm"])) + end + + def confirm_magic(token) do + with {:ok, query} <- UserToken.verify_magic_token_query(token, "magic"), + %UserToken{} = token <- Repo.one(query), + {:ok, %{user: user}} <- Repo.transaction(confirm_magic_multi(token)) do + {:ok, user} + else + _ -> :error + end + end + + defp confirm_magic_multi(%UserToken{} = token) do + Ecto.Multi.new() + |> Ecto.Multi.run(:run, fn repo, _changes -> + user = repo.get_by(User, email: token.sent_to) + if (is_nil(user)) do + UserNotifier.deliver_welcome(token.sent_to) + end + {:ok, user || %User{email: token.sent_to}} + end) + |> Ecto.Multi.insert_or_update(:user, fn %{run: user} -> User.confirm_changeset(user) end) + |> Ecto.Multi.delete_all( + :tokens, + UserToken.user_magic_and_contexts_query(token.sent_to, ["magic"]) + ) + end +end diff --git a/lib/claper/accounts/user.ex b/lib/claper/accounts/user.ex new file mode 100644 index 0000000..5a58cd7 --- /dev/null +++ b/lib/claper/accounts/user.ex @@ -0,0 +1,55 @@ +defmodule Claper.Accounts.User do + use Ecto.Schema + + import Ecto.Changeset + + schema "users" do + field :uuid, :binary_id + field :email, :string + field :is_admin, :boolean + field :confirmed_at, :naive_datetime + + has_many :events, Claper.Events.Event + + timestamps() + end + + def registration_changeset(user, attrs, _opts \\ []) do + user + |> cast(attrs, [:email]) + |> validate_email() + end + + defp validate_email(changeset) do + changeset + |> validate_required([:email]) + |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") + |> validate_length(:email, max: 160) + |> unsafe_validate_unique(:email, Claper.Repo) + |> unique_constraint(:email) + 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) do + user + |> cast(attrs, [:email]) + |> validate_email() + |> case do + %{changes: %{email: _}} = changeset -> changeset + %{} = changeset -> add_error(changeset, :email, "did not change") + end + 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 + +end diff --git a/lib/claper/accounts/user_notifier.ex b/lib/claper/accounts/user_notifier.ex new file mode 100644 index 0000000..0094b1c --- /dev/null +++ b/lib/claper/accounts/user_notifier.ex @@ -0,0 +1,83 @@ +defmodule Claper.Accounts.UserNotifier do + import Swoosh.Email + + alias Claper.Mailer + + # Delivers the email using the application mailer. + defp deliver(recipient, subject, body) do + email = + new() + |> to(recipient) + |> from({"MyApp", "contact@example.com"}) + |> subject(subject) + |> text_body(body) + + with {:ok, _metadata} <- Mailer.deliver(email) do + {:ok, email} + end + end + + def deliver_magic_link(email, url) do + email = ClaperWeb.Notifiers.UserNotifier.magic(email, url) + with {:ok, _metadata} <- Mailer.deliver(email) do + {:ok, email} + end + end + + def deliver_welcome(email) do + email = ClaperWeb.Notifiers.UserNotifier.welcome(email) + 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 + email = ClaperWeb.Notifiers.UserNotifier.update_email(user, url) + with {:ok, _metadata} <- Mailer.deliver(email) do + {:ok, email} + end + end +end diff --git a/lib/claper/accounts/user_token.ex b/lib/claper/accounts/user_token.ex new file mode 100644 index 0000000..7396f94 --- /dev/null +++ b/lib/claper/accounts/user_token.ex @@ -0,0 +1,220 @@ +defmodule Claper.Accounts.UserToken do + use Ecto.Schema + import Ecto.Query + + @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. + @confirm_validity_in_days 7 + @change_email_validity_in_days 7 + @session_validity_in_days 60 + @confirm_magic_in_minutes 5 + + schema "users_tokens" do + field :uuid, :binary_id + field :token, :binary + field :context, :string + field :sent_to, :string + belongs_to :user, Claper.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, %Claper.Accounts.UserToken{token: token, context: "session", user_id: user.id}} + end + + def build_magic_token(email, context) do + token = :crypto.strong_rand_bytes(@rand_size) + hashed_token = :crypto.hash(@hash_algorithm, token) + + {Base.url_encode64(token, padding: false), + %Claper.Accounts.UserToken{ + token: hashed_token, + context: context, + sent_to: email + }} + 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 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), + %Claper.Accounts.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 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 + + def verify_magic_token_query(token, context) do + case Base.url_decode64(token, padding: false) do + {:ok, decoded_token} -> + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + minutes = minutes_for_context(context) + + query = + from token in token_and_context_query(hashed_token, context), + where: token.inserted_at > ago(^minutes, "minute"), + select: token + + {:ok, query} + + :error -> + :error + end + end + + defp minutes_for_context("magic"), do: @confirm_magic_in_minutes + defp days_for_context("confirm"), do: @confirm_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 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 token_and_context_query(token, context) do + from Claper.Accounts.UserToken, where: [token: ^token, context: ^context] + end + + @doc """ + Gets all tokens for the given user for the given contexts. + """ + def user_and_contexts_query(user, :all) do + from t in Claper.Accounts.UserToken, where: t.user_id == ^user.id + end + + def user_and_contexts_query(user, [_ | _] = contexts) do + from t in Claper.Accounts.UserToken, where: t.user_id == ^user.id and t.context in ^contexts + end + + def user_magic_and_contexts_query(email, [_ | _] = contexts) do + from t in Claper.Accounts.UserToken, where: t.sent_to == ^email and t.context in ^contexts + end + + def user_magic_and_contexts_expiry_query(email) do + from t in Claper.Accounts.UserToken, + where: + t.sent_to == ^email and t.context == "magic" and + t.inserted_at > ago(@confirm_magic_in_minutes, "minute") + end +end diff --git a/lib/claper/application.ex b/lib/claper/application.ex new file mode 100644 index 0000000..2484928 --- /dev/null +++ b/lib/claper/application.ex @@ -0,0 +1,43 @@ +defmodule Claper.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 + topologies = Application.get_env(:libcluster, :topologies) || [] + + children = [ + {Cluster.Supervisor, [topologies, [name: Claper.ClusterSupervisor]]}, + # Start the Ecto repository + Claper.Repo, + # Start the Telemetry supervisor + ClaperWeb.Telemetry, + # Start the PubSub system + {Phoenix.PubSub, name: Claper.PubSub}, + # Start the Endpoint (http/https) + ClaperWeb.Presence, + ClaperWeb.Endpoint, + # Start a worker by calling: Claper.Worker.start_link(arg) + # {Claper.Worker, arg} + {Finch, name: Swoosh.Finch}, + {Task.Supervisor, name: Claper.TaskSupervisor} + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: Claper.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 + ClaperWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/lib/claper/events.ex b/lib/claper/events.ex new file mode 100644 index 0000000..2aa943f --- /dev/null +++ b/lib/claper/events.ex @@ -0,0 +1,267 @@ +defmodule Claper.Events do + @moduledoc """ + The Events context. + """ + + import Ecto.Query, warn: false + alias Claper.Repo + + alias Claper.Events.{Event, ActivityLeader} + + @doc """ + Returns the list of events. + + ## Examples + + iex> list_events() + [%Event{}, ...] + + """ + def list_events(user_id, preload \\ []) do + from(e in Event, where: e.user_id == ^user_id, order_by: [desc: e.expired_at]) + |> Repo.all() + |> Repo.preload(preload) + end + + def list_managed_events_by(email, preload \\ []) do + from(a in ActivityLeader, + join: u in Claper.Accounts.User, + on: u.email == a.email, + join: e in Event, + on: e.id == a.event_id, + where: a.email == ^email, + order_by: [desc: e.expired_at], + select: e + ) + |> Repo.all() + |> Repo.preload(preload) + end + + def count_events_month(user_id) do + # minus 30 days, calculated as seconds + seconds = -30 * 24 * 3600 + last_month = DateTime.utc_now() |> DateTime.add(seconds, :second) + + from(e in Event, + where: + e.user_id == ^user_id and e.inserted_at <= ^DateTime.utc_now() and + e.inserted_at >= ^last_month, + order_by: [desc: e.id] + ) + |> Repo.aggregate(:count, :id) + end + + @doc """ + Gets a single event. + + Raises `Ecto.NoResultsError` if the Event does not exist. + + ## Examples + + iex> get_event!(123) + %Event{} + + iex> get_event!(456) + ** (Ecto.NoResultsError) + + """ + def get_event!(id, preload \\ []), + do: Repo.get_by!(Event, uuid: id) |> Repo.preload(preload) + + def get_managed_event!(current_user, id, preload \\ []) do + event = Repo.get_by!(Event, uuid: id) + is_leader = Claper.Events.is_leaded_by(current_user.email, event) || event.user_id == current_user.id + if is_leader do + event |> Repo.preload(preload) + else + raise Ecto.NoResultsError + end + end + + def get_user_event!(user_id, id, preload \\ []), + do: Repo.get_by!(Event, uuid: id, user_id: user_id) |> Repo.preload(preload) + + def get_event_with_code!(code, preload \\ []) do + now = NaiveDateTime.utc_now() + + from(e in Event, where: e.code == ^code and e.expired_at > ^now) + |> Repo.one!() + |> Repo.preload(preload) + end + + def get_event_with_code(code, preload \\ []) do + now = DateTime.utc_now() + + from(e in Event, where: e.code == ^code and e.expired_at > ^now) + |> Repo.one() + |> Repo.preload(preload) + end + + def get_different_event_with_code(nil, _event_id), do: nil + + def get_different_event_with_code(code, event_id) do + now = DateTime.utc_now() + + from(e in Event, where: e.code == ^code and e.id != ^event_id and e.expired_at > ^now) + |> Repo.one() + end + + def is_leaded_by(email, event) do + from(a in ActivityLeader, + join: u in Claper.Accounts.User, + on: u.email == a.email, + join: e in Event, + on: e.id == a.event_id, + where: a.email == ^email and e.id == ^event.id, + order_by: [desc: e.expired_at] + ) + |> Repo.exists?() + end + + @doc """ + Creates a event. + + ## Examples + + iex> create_event(%{field: value}) + {:ok, %Event{}} + + iex> create_event(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_event(attrs) do + %Event{} + |> Event.create_changeset(attrs) + |> validate_unique_event() + |> case do + {:ok, event} -> + Repo.insert(event, returning: [:uuid]) + + {:error, changeset} -> + {:error, %{changeset | action: :insert}} + end + end + + defp validate_unique_event(%Ecto.Changeset{changes: %{code: code} = _changes} = event) do + case get_event_with_code(code) do + %Event{} -> {:error, Ecto.Changeset.add_error(event, :code, "Already exists")} + nil -> {:ok, event} + end + end + + defp validate_unique_event(%Ecto.Changeset{data: event} = changeset) do + case get_different_event_with_code(event.code, event.id) do + %Event{} -> {:error, Ecto.Changeset.add_error(changeset, :code, "Already exists")} + nil -> {:ok, changeset} + end + end + + @doc """ + Updates a event. + + ## Examples + + iex> update_event(event, %{field: new_value}) + {:ok, %Event{}} + + iex> update_event(event, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_event(%Event{} = event, attrs) do + event + |> Event.update_changeset(attrs) + |> validate_unique_event() + |> case do + {:ok, event} -> + Repo.update(event, returning: [:uuid]) + + {:error, changeset} -> + {:error, %{changeset | action: :update}} + end + end + + @doc """ + Deletes a event. + + ## Examples + + iex> delete_event(event) + {:ok, %Event{}} + + iex> delete_event(event) + {:error, %Ecto.Changeset{}} + + """ + def delete_event(%Event{} = event) do + Repo.delete(event) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking event changes. + + ## Examples + + iex> change_event(event) + %Ecto.Changeset{data: %Event{}} + + """ + def change_event(%Event{} = event, attrs \\ %{}) do + Event.changeset(event, attrs) + end + + alias Claper.Events.ActivityLeader + + @doc """ + Returns the list of activity_leaders. + + ## Examples + + iex> list_activity_leaders() + [%ActivityLeader{}, ...] + + """ + def list_activity_leaders do + Repo.all(ActivityLeader) + end + + @doc """ + Gets a single activity_leader. + + Raises `Ecto.NoResultsError` if the Activity leader does not exist. + + ## Examples + + iex> get_activity_leader!(123) + %ActivityLeader{} + + iex> get_activity_leader!(456) + ** (Ecto.NoResultsError) + + """ + def get_activity_leader!(id), do: Repo.get!(ActivityLeader, id) + + def get_activity_leaders_for_event(event_id) do + from(a in ActivityLeader, + left_join: u in Claper.Accounts.User, + on: u.email == a.email, + where: a.event_id == ^event_id, + select: %{a | user_id: u.id} + ) + |> Repo.all() + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking activity_leader changes. + + ## Examples + + iex> change_activity_leader(activity_leader) + %Ecto.Changeset{data: %ActivityLeader{}} + + """ + def change_activity_leader(%ActivityLeader{} = activity_leader, attrs \\ %{}) do + ActivityLeader.changeset(activity_leader, attrs) + end +end diff --git a/lib/claper/events/activity_leader.ex b/lib/claper/events/activity_leader.ex new file mode 100644 index 0000000..caf03ff --- /dev/null +++ b/lib/claper/events/activity_leader.ex @@ -0,0 +1,43 @@ +defmodule Claper.Events.ActivityLeader do + use Ecto.Schema + import Ecto.Changeset + + schema "activity_leaders" do + field :temp_id, :string, virtual: true + field :delete, :boolean, virtual: true + + field :user_id, :integer, virtual: true + + field :email, :string + belongs_to :event, Claper.Events.Event + + timestamps() + end + + @doc false + def changeset(leader, attrs) do + leader + |> Map.put(:temp_id, leader.temp_id || attrs["temp_id"]) + |> cast(attrs, [ + :email, + :event_id, + :delete + ]) + |> validate_required([:email]) + |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") + |> validate_length(:email, min: 6, max: 160) + |> unique_constraint(:email) + |> unsafe_validate_unique([:event_id, :email], Claper.Repo) + |> maybe_mark_for_deletion + end + + defp maybe_mark_for_deletion(%{data: %{id: nil}} = changeset), do: changeset + + defp maybe_mark_for_deletion(changeset) do + if get_change(changeset, :delete) do + %{changeset | action: :delete} + else + changeset + end + end +end diff --git a/lib/claper/events/event.ex b/lib/claper/events/event.ex new file mode 100644 index 0000000..0009463 --- /dev/null +++ b/lib/claper/events/event.ex @@ -0,0 +1,108 @@ +defmodule Claper.Events.Event do + use Ecto.Schema + import Ecto.Changeset + + schema "events" do + field :uuid, :binary_id + field :name, :string + field :code, :string + field :audience_peak, :integer, default: 1 + field :started_at, :naive_datetime + field :expired_at, :naive_datetime + + field :date_range, :string, virtual: true + + has_many :posts, Claper.Posts.Post + + has_many :leaders, Claper.Events.ActivityLeader, on_replace: :delete + + has_one :presentation_file, Claper.Presentations.PresentationFile + belongs_to :user, Claper.Accounts.User + + timestamps() + end + + @doc false + def changeset(event, attrs) do + event + |> cast(attrs, [ + :name, + :code, + :started_at, + :expired_at, + :date_range, + :audience_peak + ]) + |> cast_assoc(:presentation_file) + |> cast_assoc(:leaders) + |> validate_required([:code]) + |> validate_date_range + end + + def create_changeset(event, attrs) do + event + |> cast(attrs, [:name, :code, :user_id, :started_at, :expired_at, :date_range]) + |> cast_assoc(:presentation_file) + |> cast_assoc(:leaders) + |> validate_required([:code, :started_at, :expired_at]) + |> downcase_code + end + + def downcase_code(changeset) do + update_change( + changeset, + :code, + &(&1 |> String.downcase() |> String.split(~r"[^\w\d]", trim: true) |> List.first()) + ) + end + + defp validate_date_range(changeset) do + date_range = get_change(changeset, :date_range) + + if date_range != nil do + splited = date_range |> String.split(" - ") + + if splited |> Enum.count() == 2 do + changeset + |> put_change(:started_at, Enum.at(splited, 0)) + |> put_change(:expired_at, Enum.at(splited, 1)) + else + add_error(changeset, :date_range, "invalid date range") + end + else + start_date = get_change(changeset, :started_at) + end_date = get_change(changeset, :expired_at) + + if start_date != nil && end_date != nil do + changeset + |> put_change(:date_range, "#{start_date} - #{end_date}") + else + changeset + end + end + end + + def update_changeset(event, attrs) do + event + |> cast(attrs, [:name, :code, :started_at, :expired_at, :date_range, :audience_peak]) + |> cast_assoc(:presentation_file) + |> cast_assoc(:leaders) + |> validate_required([:code, :started_at, :expired_at]) + |> downcase_code + end + + def restart_changeset(event) do + expiry = + NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) |> NaiveDateTime.add(48 * 3600) + + change(event, expired_at: expiry) + end + + def subscribe(event_id) do + Phoenix.PubSub.subscribe(Claper.PubSub, "event:#{event_id}") + end + + def started?(event) do + NaiveDateTime.compare(NaiveDateTime.utc_now(), event.started_at) == :gt + end +end diff --git a/lib/claper/mailer.ex b/lib/claper/mailer.ex new file mode 100644 index 0000000..7bf9332 --- /dev/null +++ b/lib/claper/mailer.ex @@ -0,0 +1,3 @@ +defmodule Claper.Mailer do + use Swoosh.Mailer, otp_app: :claper +end diff --git a/lib/claper/polls.ex b/lib/claper/polls.ex new file mode 100644 index 0000000..c335549 --- /dev/null +++ b/lib/claper/polls.ex @@ -0,0 +1,320 @@ +defmodule Claper.Polls do + @moduledoc """ + The Polls context. + """ + + import Ecto.Query, warn: false + alias Claper.Repo + + alias Claper.Polls.Poll + alias Claper.Polls.PollOpt + alias Claper.Polls.PollVote + + @doc """ + Returns the list of polls. + + ## Examples + + iex> list_polls() + [%Poll{}, ...] + + """ + def list_polls(presentation_file_id) do + from(p in Poll, + where: p.presentation_file_id == ^presentation_file_id, + order_by: [asc: p.id, asc: p.position] + ) + |> Repo.all() + |> Repo.preload([:poll_opts]) + end + + def list_polls_at_position(presentation_file_id, position) do + from(p in Poll, + where: p.presentation_file_id == ^presentation_file_id and p.position == ^position, + order_by: [asc: p.id] + ) + |> Repo.all() + |> Repo.preload([:poll_opts]) + end + + @doc """ + Gets a single poll. + + Raises `Ecto.NoResultsError` if the Poll does not exist. + + ## Examples + + iex> get_poll!(123) + %Poll{} + + iex> get_poll!(456) + ** (Ecto.NoResultsError) + + """ + def get_poll!(id), + do: + Repo.get!(Poll, id) + |> Repo.preload( + poll_opts: + from( + o in PollOpt, + order_by: [asc: o.id] + ) + ) + |> set_percentages() + + def get_poll_current_position(presentation_file_id, position) do + from(p in Poll, + where: + p.position == ^position and p.presentation_file_id == ^presentation_file_id and + p.enabled == true + ) + |> Repo.one() + |> Repo.preload( + poll_opts: + from( + o in PollOpt, + order_by: [asc: o.id] + ) + ) + |> set_percentages() + end + + def set_percentages(%Poll{poll_opts: poll_opts} = poll) when is_list(poll_opts) do + total = Enum.map(poll.poll_opts, fn e -> e.vote_count end) |> Enum.sum() + + %{ + poll + | poll_opts: + poll.poll_opts + |> Enum.map(fn o -> %{o | percentage: calculate_percentage(o, total)} end) + } + end + + def set_percentages(poll), do: poll + + defp calculate_percentage(opt, total) do + if total > 0, + do: Float.round(opt.vote_count / total * 100) |> :erlang.float_to_binary(decimals: 0), + else: 0 + end + + @doc """ + Creates a poll. + + ## Examples + + iex> create_poll(%{field: value}) + {:ok, %Poll{}} + + iex> create_poll(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_poll(attrs \\ %{}) do + %Poll{} + |> Poll.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a poll. + + ## Examples + + iex> update_poll(poll, %{field: new_value}) + {:ok, %Poll{}} + + iex> update_poll(poll, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_poll(event_uuid, %Poll{} = poll, attrs) do + poll + |> Poll.changeset(attrs) + |> Repo.update() + |> case do + {:ok, poll} -> + broadcast({:ok, poll, event_uuid}, :poll_updated) + + {:error, changeset} -> + {:error, %{changeset | action: :update}} + end + + end + + @doc """ + Deletes a poll. + + ## Examples + + iex> delete_poll(poll) + {:ok, %Poll{}} + + iex> delete_poll(poll) + {:error, %Ecto.Changeset{}} + + """ + def delete_poll(event_uuid, %Poll{} = poll) do + {:ok, poll} = Repo.delete(poll) + broadcast({:ok, poll, event_uuid}, :poll_deleted) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking poll changes. + + ## Examples + + iex> change_poll(poll) + %Ecto.Changeset{data: %Poll{}} + + """ + def change_poll(%Poll{} = poll, attrs \\ %{}) do + Poll.changeset(poll, attrs) + end + + @doc """ + Add an empty poll opt to a poll changeset. + """ + def add_poll_opt(changeset) do + changeset + |> Ecto.Changeset.put_assoc( + :poll_opts, + Ecto.Changeset.get_field(changeset, :poll_opts) ++ [%PollOpt{}] + ) + end + + @doc """ + Remove a poll opt from a poll changeset. + """ + def remove_poll_opt(changeset, poll_opt) do + changeset + |> Ecto.Changeset.put_assoc( + :poll_opts, + Ecto.Changeset.get_field(changeset, :poll_opts) -- [poll_opt] + ) + end + + def vote(user_id, event_uuid, %PollOpt{} = poll_opt, poll_id) when is_number(user_id) do + case Ecto.Multi.new() + |> Ecto.Multi.update( + :poll_opt, + PollOpt.changeset(poll_opt, %{"vote_count" => poll_opt.vote_count + 1}) + ) + |> Ecto.Multi.insert(:poll_vote, %PollVote{ + user_id: user_id, + poll_opt_id: poll_opt.id, + poll_id: poll_id + }) + |> Repo.transaction() do + {:ok, %{poll_opt: opt}} -> + opt = + Repo.preload(opt, + poll: [ + poll_opts: + from( + o in PollOpt, + order_by: [asc: o.id] + ) + ] + ) + + broadcast({:ok, opt.poll |> set_percentages, event_uuid}, :poll_updated) + end + end + + def vote(attendee_identifier, event_uuid, %PollOpt{} = poll_opt, poll_id) do + case Ecto.Multi.new() + |> Ecto.Multi.update( + :poll_opt, + PollOpt.changeset(poll_opt, %{"vote_count" => poll_opt.vote_count + 1}) + ) + |> Ecto.Multi.insert(:poll_vote, %PollVote{ + attendee_identifier: attendee_identifier, + poll_opt_id: poll_opt.id, + poll_id: poll_id + }) + |> Repo.transaction() do + {:ok, %{poll_opt: opt}} -> + opt = + Repo.preload(opt, + poll: [ + poll_opts: + from( + o in PollOpt, + order_by: [asc: o.id] + ) + ] + ) + + broadcast({:ok, opt.poll |> set_percentages, event_uuid}, :poll_updated) + end + end + + def set_default(id, presentation_file_id, position) do + from(p in Poll, + where: + p.presentation_file_id == ^presentation_file_id and p.position == ^position and + p.id != ^id + ) + |> Repo.update_all(set: [enabled: false]) + + from(p in Poll, + where: + p.presentation_file_id == ^presentation_file_id and p.position == ^position and + p.id == ^id + ) + |> Repo.update_all(set: [enabled: true]) + end + + defp broadcast({:error, _reason} = error, _poll), do: error + + defp broadcast({:ok, poll, event_uuid}, event) do + Phoenix.PubSub.broadcast( + Claper.PubSub, + "event:#{event_uuid}", + {event, poll} + ) + + {:ok, poll} + end + + + @doc """ + Gets a single poll_vote. + + Raises `Ecto.NoResultsError` if the Poll vote does not exist. + + ## Examples + + iex> get_poll_vote!(123) + %PollVote{} + + iex> get_poll_vote!(456) + ** (Ecto.NoResultsError) + + """ + def get_poll_vote(user_id, poll_id) when is_number(user_id), + do: Repo.get_by(PollVote, poll_id: poll_id, user_id: user_id) + + def get_poll_vote(attendee_identifier, poll_id), + do: Repo.get_by(PollVote, poll_id: poll_id, attendee_identifier: attendee_identifier) + + @doc """ + Creates a poll_vote. + + ## Examples + + iex> create_poll_vote(%{field: value}) + {:ok, %PollVote{}} + + iex> create_poll_vote(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_poll_vote(attrs \\ %{}) do + %PollVote{} + |> PollVote.changeset(attrs) + |> Repo.insert() + end +end diff --git a/lib/claper/polls/poll.ex b/lib/claper/polls/poll.ex new file mode 100644 index 0000000..3973132 --- /dev/null +++ b/lib/claper/polls/poll.ex @@ -0,0 +1,26 @@ +defmodule Claper.Polls.Poll do + use Ecto.Schema + import Ecto.Changeset + + @derive {Jason.Encoder, only: [:title, :position]} + schema "polls" do + field :title, :string + field :position, :integer + field :total, :integer, virtual: true + field :enabled, :boolean + + belongs_to :presentation_file, Claper.Presentations.PresentationFile + has_many :poll_opts, Claper.Polls.PollOpt, on_replace: :delete + has_many :poll_votes, Claper.Polls.PollVote, on_replace: :delete + + timestamps() + end + + @doc false + def changeset(poll, attrs) do + poll + |> cast(attrs, [:title, :presentation_file_id, :position, :enabled, :total]) + |> cast_assoc(:poll_opts, required: true) + |> validate_required([:title, :presentation_file_id, :position]) + end +end diff --git a/lib/claper/polls/poll_opt.ex b/lib/claper/polls/poll_opt.ex new file mode 100644 index 0000000..071a2e5 --- /dev/null +++ b/lib/claper/polls/poll_opt.ex @@ -0,0 +1,23 @@ +defmodule Claper.Polls.PollOpt do + use Ecto.Schema + import Ecto.Changeset + + @derive {Jason.Encoder, only: [:content, :vote_count]} + schema "poll_opts" do + field :content, :string + field :vote_count, :integer + field :percentage, :float, virtual: true + + belongs_to :poll, Claper.Polls.Poll + has_many :poll_votes, Claper.Polls.PollVote, on_replace: :delete + + timestamps() + end + + @doc false + def changeset(poll_opt, attrs) do + poll_opt + |> cast(attrs, [:content, :vote_count, :poll_id]) + |> validate_required([:content]) + end +end diff --git a/lib/claper/polls/poll_vote.ex b/lib/claper/polls/poll_vote.ex new file mode 100644 index 0000000..b96a8da --- /dev/null +++ b/lib/claper/polls/poll_vote.ex @@ -0,0 +1,21 @@ +defmodule Claper.Polls.PollVote do + use Ecto.Schema + import Ecto.Changeset + + schema "poll_votes" do + field :attendee_identifier, :string + + belongs_to :poll, Claper.Polls.Poll + belongs_to :poll_opt, Claper.Polls.PollOpt + belongs_to :user, Claper.Accounts.User + + timestamps() + end + + @doc false + def changeset(poll_vote, attrs) do + poll_vote + |> cast(attrs, [:attendee_identifier, :user_id, :poll_opt_id, :poll_id]) + |> validate_required([:poll_opt_id, :poll_id]) + end +end diff --git a/lib/claper/posts.ex b/lib/claper/posts.ex new file mode 100644 index 0000000..e59c7f9 --- /dev/null +++ b/lib/claper/posts.ex @@ -0,0 +1,233 @@ +defmodule Claper.Posts do + @moduledoc """ + The Posts context. + """ + + import Ecto.Query, warn: false + alias Claper.Repo + + alias Claper.Posts.Post + + @doc """ + Get event posts + + """ + def list_posts(event_id, preload \\ []) do + from(p in Post, + join: e in Claper.Events.Event, + on: p.event_id == e.id, + select: p, + where: e.uuid == ^event_id, + order_by: [asc: p.id] + ) + |> Repo.all() + |> Repo.preload(preload) + end + + def reacted_posts(event_id, user_id, icon) when is_number(user_id) do + from(reaction in Claper.Posts.Reaction, + join: post in Claper.Posts.Post, + where: + reaction.icon == ^icon and reaction.post_id == post.id and reaction.user_id == ^user_id and + post.event_id == ^event_id, + distinct: true, + select: reaction.post_id + ) + |> Repo.all() + end + + def reacted_posts(event_id, attendee_identifier, icon) do + from(reaction in Claper.Posts.Reaction, + join: post in Claper.Posts.Post, + where: + reaction.icon == ^icon and reaction.post_id == post.id and + reaction.attendee_identifier == ^attendee_identifier and post.event_id == ^event_id, + distinct: true, + select: reaction.post_id + ) + |> Repo.all() + end + + @doc """ + Gets a single post. + + Raises `Ecto.NoResultsError` if the Post does not exist. + + ## Examples + + iex> get_post!(123) + %Post{} + + iex> get_post!(456) + ** (Ecto.NoResultsError) + + """ + def get_post!(id, preload \\ []), do: Repo.get_by!(Post, uuid: id) |> Repo.preload(preload) + + @doc """ + Creates a post. + + ## Examples + + iex> create_post(%{field: value}) + {:ok, %Post{}} + + iex> create_post(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_post(event, attrs) do + %Post{} + |> Map.put(:event, event) + |> Post.changeset(attrs) + |> Repo.insert(returning: [:uuid]) + |> broadcast(:post_created) + end + + @doc """ + Updates a post. + + ## Examples + + iex> update_post(post, %{field: new_value}) + {:ok, %Post{}} + + iex> update_post(post, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_post(%Post{} = post, attrs) do + post + |> Post.changeset(attrs) + |> Repo.update() + |> broadcast(:post_updated) + end + + @doc """ + Deletes a post. + + ## Examples + + iex> delete_post(post) + {:ok, %Post{}} + + iex> delete_post(post) + {:error, %Ecto.Changeset{}} + + """ + def delete_post(%Post{} = post) do + post + |> Repo.delete() + |> broadcast(:post_deleted) + end + + def delete_all_posts(:attendee_identifier, attendee_identifier, event) do + posts = + from(post in Claper.Posts.Post, + where: post.attendee_identifier == ^attendee_identifier and post.event_id == ^event.id + ) + |> Repo.all() + + for post <- posts do + delete_post(%{post | event: event}) + end + end + + def delete_all_posts(:user_id, user_id, event) do + posts = + from(post in Claper.Posts.Post, + where: post.user_id == ^user_id and post.event_id == ^event.id + ) + |> Repo.all() + + for post <- posts do + delete_post(%{post | event: event}) + end + end + + alias Claper.Posts.{Reaction, Post} + + @doc """ + Gets a single reaction. + + Raises `Ecto.NoResultsError` if the Reaction does not exist. + + ## Examples + + iex> get_reaction!(123) + %Reaction{} + + iex> get_reaction!(456) + ** (Ecto.NoResultsError) + + """ + def get_reaction!(id), do: Repo.get!(Reaction, id) + + @doc """ + Creates a reaction. + + ## Examples + + iex> create_reaction(%{field: value}) + {:ok, %Reaction{}} + + iex> create_reaction(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_reaction(%{post: nil} = attrs), do: create_reaction(%{attrs | post: %Post{}}) + def create_reaction(%{post: post} = attrs) do + case %Reaction{} + |> Map.put(:post_id, post.id) + |> Reaction.changeset(attrs) + |> Repo.insert() do + {:ok, reaction} -> + broadcast({:ok, post}, :reaction_added) + {:ok, reaction} + + {:error, changeset} -> + {:error, changeset} + end + end + + @doc """ + Deletes a reaction. + + ## Examples + + iex> delete_reaction(reaction) + {:ok, %Reaction{}} + + iex> delete_reaction(reaction) + {:error, %Ecto.Changeset{}} + + """ + def delete_reaction(%{user_id: user_id, post: post, icon: icon} = _params) + when is_integer(user_id) do + with reaction <- Repo.get_by!(Reaction, post_id: post.id, user_id: user_id, icon: icon) do + Repo.delete(reaction) + broadcast({:ok, post}, :reaction_removed) + end + end + + def delete_reaction( + %{attendee_identifier: attendee_identifier, post: post, icon: icon} = _params + ) do + with reaction <- + Repo.get_by!(Reaction, + post_id: post.id, + attendee_identifier: attendee_identifier, + icon: icon + ) do + Repo.delete(reaction) + broadcast({:ok, post}, :reaction_removed) + end + end + + defp broadcast({:error, _reason} = error, _event), do: error + + defp broadcast({:ok, post}, event) do + Phoenix.PubSub.broadcast(Claper.PubSub, "event:#{post.event.uuid}", {event, post}) + {:ok, post} + end +end diff --git a/lib/claper/posts/post.ex b/lib/claper/posts/post.ex new file mode 100644 index 0000000..3da80e4 --- /dev/null +++ b/lib/claper/posts/post.ex @@ -0,0 +1,30 @@ +defmodule Claper.Posts.Post do + use Ecto.Schema + import Ecto.Changeset + + schema "posts" do + field :body, :string + field :uuid, :binary_id + field :like_count, :integer, default: 0 + field :love_count, :integer, default: 0 + field :lol_count, :integer, default: 0 + field :name, :string + field :attendee_identifier, :string + field :position, :integer, default: 0 + + belongs_to :event, Claper.Events.Event + belongs_to :user, Claper.Accounts.User + has_many :reactions, Claper.Posts.Reaction + + timestamps() + end + + @doc false + def changeset(post, attrs) do + post + |> cast(attrs, [:body, :attendee_identifier, :user_id, :like_count, :love_count, :lol_count, :position]) + |> validate_required([:body, :position]) + |> validate_length(:body, min: 2, max: 250) + end + +end diff --git a/lib/claper/posts/reaction.ex b/lib/claper/posts/reaction.ex new file mode 100644 index 0000000..a4c8f95 --- /dev/null +++ b/lib/claper/posts/reaction.ex @@ -0,0 +1,21 @@ +defmodule Claper.Posts.Reaction do + use Ecto.Schema + import Ecto.Changeset + + schema "reactions" do + field :icon, :string + field :attendee_identifier, :string + + belongs_to :post, Claper.Posts.Post + belongs_to :user, Claper.Accounts.User + + timestamps() + end + + @doc false + def changeset(reaction, attrs) do + reaction + |> cast(attrs, [:icon, :attendee_identifier, :user_id, :post_id]) + |> validate_required([:icon, :post_id]) + end +end diff --git a/lib/claper/presentations.ex b/lib/claper/presentations.ex new file mode 100644 index 0000000..c87ac75 --- /dev/null +++ b/lib/claper/presentations.ex @@ -0,0 +1,121 @@ +defmodule Claper.Presentations do + @moduledoc """ + The Presentations context. + """ + + import Ecto.Query, warn: false + alias Claper.Repo + + alias Claper.Presentations.PresentationFile + + @doc """ + Gets a single presentation_files. + + Raises `Ecto.NoResultsError` if the Presentation files does not exist. + + ## Examples + + iex> get_presentation_file!(123) + %PresentationFile{} + + iex> get_presentation_file!(456) + ** (Ecto.NoResultsError) + + """ + def get_presentation_file!(id, preload \\ []), + do: Repo.get!(PresentationFile, id) |> Repo.preload(preload) + + def get_presentation_file_by_hash!(hash) when is_binary(hash), + do: Repo.get_by(PresentationFile, hash: hash) |> Repo.preload([:event]) + + @doc """ + Creates a presentation_files. + + ## Examples + + iex> create_presentation_file(%{field: value}) + {:ok, %PresentationFile{}} + + iex> create_presentation_file(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_presentation_file(attrs \\ %{}) do + %PresentationFile{} + |> PresentationFile.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a presentation_files. + + ## Examples + + iex> update_presentation_file(presentation_files, %{field: new_value}) + {:ok, %PresentationFile{}} + + iex> update_presentation_file(presentation_files, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_presentation_file(%PresentationFile{} = presentation_file, attrs) do + presentation_file + |> PresentationFile.changeset(attrs) + |> Repo.update() + end + + def subscribe(presentation_file_id) do + Phoenix.PubSub.subscribe(Claper.PubSub, "presentation:#{presentation_file_id}") + end + + alias Claper.Presentations.PresentationState + + @doc """ + Creates a presentation_state. + + ## Examples + + iex> create_presentation_state(%{field: value}) + {:ok, %PresentationState{}} + + iex> create_presentation_state(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_presentation_state(attrs \\ %{}) do + %PresentationState{} + |> PresentationState.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a presentation_state. + + ## Examples + + iex> update_presentation_state(presentation_state, %{field: new_value}) + {:ok, %PresentationState{}} + + iex> update_presentation_state(presentation_state, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_presentation_state(%PresentationState{} = presentation_state, attrs) do + presentation_state + |> PresentationState.changeset(attrs) + |> Repo.update() + |> broadcast(:state_updated) + end + + defp broadcast({:error, _reason} = error, _state), do: error + + defp broadcast({:ok, state}, event) do + Phoenix.PubSub.broadcast( + Claper.PubSub, + "presentation:#{state.presentation_file_id}", + {event, state} + ) + + {:ok, state} + end +end diff --git a/lib/claper/presentations/presentation_file.ex b/lib/claper/presentations/presentation_file.ex new file mode 100644 index 0000000..805bb58 --- /dev/null +++ b/lib/claper/presentations/presentation_file.ex @@ -0,0 +1,23 @@ +defmodule Claper.Presentations.PresentationFile do + use Ecto.Schema + import Ecto.Changeset + + schema "presentation_files" do + field :hash, :string + field :length, :integer + field :status, :string + + belongs_to :event, Claper.Events.Event + has_many :polls, Claper.Polls.Poll + has_one :presentation_state, Claper.Presentations.PresentationState, on_replace: :delete + + timestamps() + end + + @doc false + def changeset(presentation_file, attrs) do + presentation_file + |> cast(attrs, [:length, :status, :hash]) + |> cast_assoc(:presentation_state) + end +end diff --git a/lib/claper/presentations/presentation_state.ex b/lib/claper/presentations/presentation_state.ex new file mode 100644 index 0000000..75b8efc --- /dev/null +++ b/lib/claper/presentations/presentation_state.ex @@ -0,0 +1,23 @@ +defmodule Claper.Presentations.PresentationState do + use Ecto.Schema + import Ecto.Changeset + + schema "presentation_states" do + field :position, :integer + field :chat_visible, :boolean + field :poll_visible, :boolean + field :join_screen_visible, :boolean + field :banned, {:array, :string}, default: [] + + belongs_to :presentation_file, Claper.Presentations.PresentationFile + + timestamps() + end + + @doc false + def changeset(presentation_state, attrs) do + presentation_state + |> cast(attrs, [:position, :chat_visible, :poll_visible, :join_screen_visible, :banned]) + |> validate_required([]) + end +end diff --git a/lib/claper/release.ex b/lib/claper/release.ex new file mode 100644 index 0000000..3683f93 --- /dev/null +++ b/lib/claper/release.ex @@ -0,0 +1,30 @@ +defmodule Claper.Release do + @moduledoc """ + Used for executing DB release tasks when run in production without Mix + installed. + """ + @app :claper + + def migrate do + load_app() + + Application.ensure_all_started(:ssl) + + 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 diff --git a/lib/claper/repo.ex b/lib/claper/repo.ex new file mode 100644 index 0000000..4b953f3 --- /dev/null +++ b/lib/claper/repo.ex @@ -0,0 +1,9 @@ +defmodule Claper.Repo do + use Ecto.Repo, + otp_app: :claper, + adapter: Ecto.Adapters.Postgres + + def init(_type, config) do + {:ok, Keyword.put(config, :url, System.get_env("DATABASE_URL"))} + end +end diff --git a/lib/claper/schema.ex b/lib/claper/schema.ex new file mode 100644 index 0000000..b8da11e --- /dev/null +++ b/lib/claper/schema.ex @@ -0,0 +1,9 @@ +defmodule Claper.Schema do + defmacro __using__(_) do + quote do + use Ecto.Schema + @primary_key {:id, :binary_id, autogenerate: false} + @foreign_key_type :binary_id + end + end +end diff --git a/lib/claper/stats.ex b/lib/claper/stats.ex new file mode 100644 index 0000000..3145912 --- /dev/null +++ b/lib/claper/stats.ex @@ -0,0 +1,27 @@ +defmodule Claper.Stats do + @moduledoc """ + The Stats context. + """ + + import Ecto.Query, warn: false + alias Claper.Repo + + def distinct_poster_count(event_id) do + from(posts in Claper.Posts.Post, + where: posts.event_id == ^event_id, + select: count(posts.user_id, :distinct) + count(posts.attendee_identifier, :distinct) + ) + |> Repo.one() + end + + def total_vote_count(presentation_file_id) do + from(p in Claper.Polls.Poll, + join: o in Claper.Polls.PollOpt, + on: o.poll_id == p.id, + where: p.presentation_file_id == ^presentation_file_id, + group_by: o.poll_id, + select: sum(o.vote_count) + ) + |> Repo.all() + end +end diff --git a/lib/claper/tasks/converter.ex b/lib/claper/tasks/converter.ex new file mode 100644 index 0000000..6387cf9 --- /dev/null +++ b/lib/claper/tasks/converter.ex @@ -0,0 +1,197 @@ +defmodule Claper.Tasks.Converter do + @moduledoc """ + This module is used to convert presentations from PDF or PPT to images. + We use a hash to identify the presentation. A new hash is generated when the conversion is finished and the presentation is being uploaded. + """ + + alias ExAws.S3 + alias Porcelain.Result + + @doc """ + Convert the presentation file to images. + We use original hash :erlang.phash2(code-name) where the files are uploaded to send it to another folder with a new hash. This last stored in db. + """ + def convert(user_id, file, hash, ext, presentation_file_id) do + presentation = Claper.Presentations.get_presentation_file!(presentation_file_id, [:event]) + + {:ok, presentation} = + Claper.Presentations.update_presentation_file(presentation, %{ + "status" => "progress" + }) + + Phoenix.PubSub.broadcast( + Claper.PubSub, + "events:#{user_id}", + {:presentation_file_process_done, presentation} + ) + + path = + Path.join([ + :code.priv_dir(:claper), + "static", + "uploads", + "#{hash}" + ]) + + IO.puts("Starting conversion for #{hash}...") + + file_to_pdf(String.to_atom(ext), path, file) + |> pdf_to_jpg(path, presentation, user_id) + |> jpg_upload(hash, path, presentation, user_id) + end + + @doc """ + Remove the presentation files directory + """ + def clear(hash) do + IO.puts("Clearing #{hash}...") + + if System.get_env("PRESENTATION_STORAGE") == "local" do + File.rm_rf(Path.join([ + :code.priv_dir(:claper), + "static", + "uploads", + "#{hash}" + ])) + + else + stream = + ExAws.S3.list_objects(System.get_env("AWS_PRES_BUCKET"), prefix: "presentations/#{hash}") + |> ExAws.stream!() + |> Stream.map(& &1.key) + + ExAws.S3.delete_all_objects(System.get_env("AWS_PRES_BUCKET"), stream) |> ExAws.request() + end + end + + defp file_to_pdf(:ppt, path, file) do + Porcelain.exec( + "libreoffice", + [ + "--headless", + "--invisible", + "--convert-to", + "pdf", + "--outdir", + path, + "#{path}/#{file}" + ] + ) + end + + defp file_to_pdf(:pptx, path, file) do + Porcelain.exec( + "libreoffice", + [ + "--headless", + "--invisible", + "--convert-to", + "pdf", + "--outdir", + path, + "#{path}/#{file}" + ] + ) + end + + defp file_to_pdf(_ext, _path, _file), do: %Result{status: 0} + + defp pdf_to_jpg(%Result{status: 0}, path, _presentation, _user_id) do + Porcelain.exec( + "gs", + [ + "-sDEVICE=png16m", + "-o#{path}/%d.jpg", + "-r300x300", + "-dNOPAUSE", + "-dBATCH", + "#{path}/original.pdf" + ] + ) + end + + defp pdf_to_jpg(_result, path, presentation, user_id) do + failure(presentation, path, user_id) + end + + defp jpg_upload(%Result{status: 0}, hash, path, presentation, user_id) do + + files = Path.wildcard("#{path}/*.jpg") + + # assign new hash to avoid cache issues + new_hash = :erlang.phash2("#{hash}-#{System.system_time(:second)}") + + if System.get_env("PRESENTATION_STORAGE") == "local" do + + File.rename(Path.join([ + :code.priv_dir(:claper), + "static", + "uploads", + "#{hash}" + ]), Path.join([ + :code.priv_dir(:claper), + "static", + "uploads", + "#{new_hash}" + ])) + + else + + for f <- files do + IO.puts("Uploads #{f} to presentations/#{new_hash}/#{Path.basename(f)}") + + f + |> S3.Upload.stream_file() + |> S3.upload( + System.get_env("AWS_PRES_BUCKET"), + "presentations/#{new_hash}/#{Path.basename(f)}", + acl: "public-read" + ) + |> ExAws.request() + end + + end + + if !is_nil(presentation.hash) do + clear(presentation.hash) + end + + success(presentation, path, new_hash, length(files), user_id) + end + + defp jpg_upload(_result, _hash, path, presentation, user_id) do + failure(presentation, path, user_id) + end + + defp success(presentation, path, hash, length, user_id) do + with {:ok, presentation} <- + Claper.Presentations.update_presentation_file(presentation, %{ + "hash" => "#{hash}", + "length" => length, + "status" => "done" + }) do + unless System.get_env("PRESENTATION_STORAGE") == "local", do: File.rm_rf!(path) + + Phoenix.PubSub.broadcast( + Claper.PubSub, + "events:#{user_id}", + {:presentation_file_process_done, presentation} + ) + end + end + + defp failure(presentation, path, user_id) do + with {:ok, presentation} <- + Claper.Presentations.update_presentation_file(presentation, %{ + "status" => "fail" + }) do + File.rm_rf!(path) + + Phoenix.PubSub.broadcast( + Claper.PubSub, + "events:#{user_id}", + {:presentation_file_process_done, presentation} + ) + end + end +end diff --git a/lib/claper_web.ex b/lib/claper_web.ex new file mode 100644 index 0000000..52796fe --- /dev/null +++ b/lib/claper_web.ex @@ -0,0 +1,114 @@ +defmodule ClaperWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, views, channels and so on. + + This can be used in your application as: + + use ClaperWeb, :controller + use ClaperWeb, :view + + The definitions below will be executed for every view, + controller, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. Instead, define any helper function in modules + and import those modules here. + """ + + def controller do + quote do + use Phoenix.Controller, namespace: ClaperWeb + + import Plug.Conn + import ClaperWeb.Gettext + alias ClaperWeb.Router.Helpers, as: Routes + end + end + + def view do + quote do + use Phoenix.View, + root: "lib/claper_web/templates", + namespace: ClaperWeb + + # Import convenience functions from controllers + import Phoenix.Controller, + only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] + + # Include shared imports and aliases for views + unquote(view_helpers()) + end + end + + def live_view do + quote do + use Phoenix.LiveView, + layout: {ClaperWeb.LayoutView, "live.html"} + + unquote(view_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(view_helpers()) + end + end + + def router do + quote do + use Phoenix.Router + + import Plug.Conn + import Phoenix.Controller + import Phoenix.LiveView.Router + end + end + + def view_component do + quote do + use Phoenix.HTML + use Phoenix.Component + import ClaperWeb.ErrorHelpers + alias Phoenix.LiveView.JS + end + end + + def channel do + quote do + use Phoenix.Channel + import Phoenix.View + import ClaperWeb.Gettext + end + end + + defp view_helpers do + quote do + # Use all HTML functionality (forms, tags, etc) + use Phoenix.HTML + + # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) + import Phoenix.LiveView.Helpers + import ClaperWeb.LiveHelpers + alias Phoenix.LiveView.JS + + # Import basic rendering functionality (render, render_layout, etc) + import Phoenix.View + + import ClaperWeb.ErrorHelpers + import ClaperWeb.Gettext + alias ClaperWeb.Router.Helpers, as: Routes + end + end + + @doc """ + When used, dispatch to the appropriate controller/view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/lib/claper_web/channels/presence.ex b/lib/claper_web/channels/presence.ex new file mode 100644 index 0000000..52c9fa5 --- /dev/null +++ b/lib/claper_web/channels/presence.ex @@ -0,0 +1,10 @@ +defmodule ClaperWeb.Presence do + @moduledoc """ + Provides presence tracking to channels and processes. + + See the [`Phoenix.Presence`](http://hexdocs.pm/phoenix/Phoenix.Presence.html) + docs for more details. + """ + use Phoenix.Presence, otp_app: :claper, + pubsub_server: Claper.PubSub +end diff --git a/lib/claper_web/controllers/event_controller.ex b/lib/claper_web/controllers/event_controller.ex new file mode 100644 index 0000000..ec5aaa4 --- /dev/null +++ b/lib/claper_web/controllers/event_controller.ex @@ -0,0 +1,71 @@ +defmodule ClaperWeb.EventController do + use ClaperWeb, :controller + + def attendee_identifier(conn, _opts) do + conn |> set_token() + end + + defp set_token(conn) do + if is_nil(get_session(conn, :attendee_identifier)) do + token = Base.url_encode64(:crypto.strong_rand_bytes(8)) + + conn + |> put_session(:attendee_identifier, token) + else + conn + end + end + + def slide_generate(conn, %{"uuid" => uuid, "qr" => qr} = _opts) do + with event <- Claper.Events.get_event!(uuid) do + "data:image/png;base64," <> raw = qr + {:ok, data} = Base.decode64(raw) + dir = System.tmp_dir!() + tmp_file = Path.join(dir, "qr-#{uuid}.png") + File.write!(tmp_file, data, [:binary]) + + code = String.upcase(event.code) + + {output, 0} = + System.cmd("convert", [ + "-size", + "1920x1080", + "xc:black", + "-fill", + "white", + "-font", + "Roboto", + "-pointsize", + "45", + "-gravity", + "north", + "-annotate", + "+0+100", + "Scannez pour interagir en temps-réel", + "-gravity", + "center", + "-annotate", + "+0+200", + "Ou allez sur Claper.co et utilisez le code:", + "-pointsize", + "65", + "-gravity", + "center", + "-annotate", + "+0+350", + "##{code}", + tmp_file, + "-gravity", + "north", + "-geometry", + "+0+230", + "-composite", + "jpg:-" + ]) + + conn + |> put_resp_content_type("image/png") + |> send_resp(200, output) + end + end +end diff --git a/lib/claper_web/controllers/page_controller.ex b/lib/claper_web/controllers/page_controller.ex new file mode 100644 index 0000000..3a6f382 --- /dev/null +++ b/lib/claper_web/controllers/page_controller.ex @@ -0,0 +1,18 @@ +defmodule ClaperWeb.PageController do + use ClaperWeb, :controller + + def index(conn, _params) do + conn + |> render("index.html") + end + + def tos(conn, _params) do + conn + |> render("tos.html") + end + + def privacy(conn, _params) do + conn + |> render("privacy.html") + end +end diff --git a/lib/claper_web/controllers/post_controller.ex b/lib/claper_web/controllers/post_controller.ex new file mode 100644 index 0000000..ecf721d --- /dev/null +++ b/lib/claper_web/controllers/post_controller.ex @@ -0,0 +1,32 @@ +defmodule ClaperWeb.PostController do + use ClaperWeb, :controller + + def index(conn, %{"event_id" => event_id}) do + try do + with event <- Claper.Events.get_event!(event_id), + posts <- Claper.Posts.list_posts(event.uuid, [:user, :attendee]) do + render(conn, "index.json", posts: posts) + end + rescue + Ecto.NoResultsError -> conn + |> put_status(:not_found) + |> put_view(ClaperWeb.ErrorView) + |> render(:"404") + end + end + def create(conn, %{"event_id" => event_id, "body" => body}) do + try do + with event <- Claper.Events.get_event!(event_id) do + case Claper.Posts.create_post(event, %{body: body}) do + {:ok, post} -> render(conn, "post.json", post: post) + end + + end + rescue + Ecto.NoResultsError -> conn + |> put_status(:not_found) + |> put_view(ClaperWeb.ErrorView) + |> render(:"404") + end + end +end diff --git a/lib/claper_web/controllers/user_auth.ex b/lib/claper_web/controllers/user_auth.ex new file mode 100644 index 0000000..779b14d --- /dev/null +++ b/lib/claper_web/controllers/user_auth.ex @@ -0,0 +1,154 @@ +defmodule ClaperWeb.UserAuth do + import Plug.Conn + import Phoenix.Controller + + alias Claper.Accounts + alias ClaperWeb.Router.Helpers, as: Routes + + # 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 "_claper_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_session(:user_token, token) + |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(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 + 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_session_token(user_token) + + if live_socket_id = get_session(conn, :live_socket_id) do + ClaperWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) + end + + conn + |> renew_session() + |> delete_resp_cookie(@remember_me_cookie) + |> redirect(to: "/") + 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) + conn |> put_session(:current_user, user) |> assign(:current_user, user) + end + + defp ensure_user_token(conn) do + if user_token = get_session(conn, :user_token) do + {user_token, conn} + else + conn = fetch_cookies(conn, signed: [@remember_me_cookie]) + + if user_token = conn.cookies[@remember_me_cookie] do + {user_token, put_session(conn, :user_token, user_token)} + else + {nil, conn} + 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 + if conn.assigns[:current_user].confirmed_at do + conn + else + conn + |> redirect(to: Routes.user_registration_path(conn, :confirm)) + end + else + conn + |> put_flash(:error, "You must log in to access this page.") + |> maybe_store_return_to() + |> redirect(to: Routes.user_session_path(conn, :new)) + |> halt() + end + 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: "/events" +end diff --git a/lib/claper_web/controllers/user_confirmation_controller.ex b/lib/claper_web/controllers/user_confirmation_controller.ex new file mode 100644 index 0000000..a9c6354 --- /dev/null +++ b/lib/claper_web/controllers/user_confirmation_controller.ex @@ -0,0 +1,80 @@ +defmodule ClaperWeb.UserConfirmationController do + use ClaperWeb, :controller + + alias Claper.Accounts + + def new(conn, _params) do + render(conn, "new.html") + end + + def create(conn, %{"user" => %{"email" => email}}) do + if user = Accounts.get_user_by_email(email) do + Accounts.deliver_user_confirmation_instructions( + user, + &Routes.user_confirmation_url(conn, :edit, &1) + ) + end + + # In order to prevent user enumeration attacks, regardless of the outcome, show an impartial success/error message. + conn + |> put_flash( + :info, + "If your email is in our system and it has not been confirmed yet, " <> + "you will receive an email with instructions shortly." + ) + |> redirect(to: "/") + end + + def edit(conn, %{"token" => token}) do + render(conn, "edit.html", token: token) + end + + # Do not log in the user after confirmation to avoid a + # leaked token giving the user access to the account. + def update(conn, %{"token" => token}) do + case Accounts.confirm_user(token) do + {:ok, _} -> + conn + |> put_flash(:info, "User confirmed successfully.") + |> redirect(to: "/") + + :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 conn.assigns do + %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> + redirect(conn, to: "/") + + %{} -> + conn + |> put_flash(:error, "User confirmation link is invalid or it has expired.") + |> redirect(to: "/") + end + end + end + + def confirm_magic(conn, %{"token" => token} = _params) do + case Accounts.confirm_magic(token) do + {:ok, user} -> + conn + |> ClaperWeb.UserAuth.log_in_user(user, %{"remember_me" => "true"}) + + :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 conn.assigns do + %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> + redirect(conn, to: "/") + + %{} -> + conn + |> put_flash(:error, "Magic link is invalid or has expired.") + |> redirect(to: "/") + end + end + end +end diff --git a/lib/claper_web/controllers/user_registration_controller.ex b/lib/claper_web/controllers/user_registration_controller.ex new file mode 100644 index 0000000..ef90681 --- /dev/null +++ b/lib/claper_web/controllers/user_registration_controller.ex @@ -0,0 +1,34 @@ +defmodule ClaperWeb.UserRegistrationController do + use ClaperWeb, :controller + + alias Claper.Accounts + alias Claper.Accounts.User + alias ClaperWeb.UserAuth + + def new(conn, _params) do + changeset = Accounts.change_user_registration(%User{}) + render(conn, "new.html", changeset: changeset) + end + + def confirm(conn, _params) do + render(conn, "confirm.html") + end + + def create(conn, %{"user" => user_params}) do + case Accounts.register_user(user_params) do + {:ok, user} -> + {:ok, _} = + Accounts.deliver_user_confirmation_instructions( + user, + &Routes.user_confirmation_url(conn, :edit, &1) + ) + + conn + |> put_flash(:info, "User created successfully.") + |> UserAuth.log_in_user(user) + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "new.html", changeset: changeset) + end + end +end diff --git a/lib/claper_web/controllers/user_session_controller.ex b/lib/claper_web/controllers/user_session_controller.ex new file mode 100644 index 0000000..0fd7662 --- /dev/null +++ b/lib/claper_web/controllers/user_session_controller.ex @@ -0,0 +1,23 @@ +defmodule ClaperWeb.UserSessionController do + use ClaperWeb, :controller + + alias Claper.Accounts + alias ClaperWeb.UserAuth + + def new(conn, _params) do + conn + |> render("new.html", error_message: nil) + end + + def create(conn, %{"user" => %{"email" => email}} = _user_params) do + Accounts.deliver_magic_link(email, &Routes.user_confirmation_url(conn, :confirm_magic, &1)) + + conn + |> redirect(to: Routes.user_registration_path(conn, :confirm, %{email: email})) + end + + def delete(conn, _params) do + conn + |> UserAuth.log_out_user() + end +end diff --git a/lib/claper_web/controllers/user_settings_controller.ex b/lib/claper_web/controllers/user_settings_controller.ex new file mode 100644 index 0000000..6bc6d12 --- /dev/null +++ b/lib/claper_web/controllers/user_settings_controller.ex @@ -0,0 +1,56 @@ +defmodule ClaperWeb.UserSettingsController do + use ClaperWeb, :controller + + alias Claper.Accounts + + plug :assign_email_and_password_changesets + + def edit(conn, _params) do + render(conn, "edit.html") + end + + def update(conn, %{"action" => "update_email"} = params) do + %{"user" => user_params} = params + user = conn.assigns.current_user + + case Accounts.apply_user_email(user, user_params) do + {:ok, applied_user} -> + Accounts.deliver_update_email_instructions( + applied_user, + user.email, + &Routes.user_settings_url(conn, :confirm_email, &1) + ) + + conn + |> put_flash( + :info, + "A link to confirm your email change has been sent to the new address." + ) + |> redirect(to: Routes.user_settings_show_path(conn, :show)) + + {:error, changeset} -> + render(conn, "edit.html", email_changeset: changeset) + end + end + + def confirm_email(conn, %{"token" => token}) do + case Accounts.update_user_email(conn.assigns.current_user, token) do + :ok -> + conn + |> put_flash(:info, "Email changed successfully.") + |> redirect(to: Routes.user_settings_show_path(conn, :show)) + + :error -> + conn + |> put_flash(:error, "Email change link is invalid or it has expired.") + |> redirect(to: Routes.user_settings_show_path(conn, :show)) + end + end + + defp assign_email_and_password_changesets(conn, _opts) do + user = conn.assigns.current_user + + conn + |> assign(:email_changeset, Accounts.change_user_email(user)) + end +end diff --git a/lib/claper_web/endpoint.ex b/lib/claper_web/endpoint.ex new file mode 100644 index 0000000..2b73f2f --- /dev/null +++ b/lib/claper_web/endpoint.ex @@ -0,0 +1,51 @@ +defmodule ClaperWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :claper + + # 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: "_claper_key", + signing_salt: "Tg18Y2zU" + ] + + socket "/live", Phoenix.LiveView.Socket, websocket: [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: :claper, + gzip: false, + only: + ~w(assets fonts .well-known uploads images favicon.ico robots.txt loaderio-eb3b956a176cdd4f54eb8570ce8bbb06.txt) + + # 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: :claper + 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 ClaperWeb.Router +end diff --git a/lib/claper_web/gettext.ex b/lib/claper_web/gettext.ex new file mode 100644 index 0000000..47262d4 --- /dev/null +++ b/lib/claper_web/gettext.ex @@ -0,0 +1,24 @@ +defmodule ClaperWeb.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 ClaperWeb.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: :claper +end diff --git a/lib/claper_web/live/attendee_live_auth.ex b/lib/claper_web/live/attendee_live_auth.ex new file mode 100644 index 0000000..b354edd --- /dev/null +++ b/lib/claper_web/live/attendee_live_auth.ex @@ -0,0 +1,12 @@ +defmodule ClaperWeb.AttendeeLiveAuth do + import Phoenix.LiveView + + def on_mount(:default, _params, session, socket) do + socket = + socket + |> assign(:attendee_identifier, session["attendee_identifier"]) + |> assign(:current_user, session["current_user"]) + + {:cont, socket} + end +end diff --git a/lib/claper_web/live/event_live/event_card_component.ex b/lib/claper_web/live/event_live/event_card_component.ex new file mode 100644 index 0000000..3ef1f6a --- /dev/null +++ b/lib/claper_web/live/event_live/event_card_component.ex @@ -0,0 +1,135 @@ +defmodule ClaperWeb.EventLive.EventCardComponent do + use ClaperWeb, :live_component + + def render(assigns) do + assigns = + assigns + |> assign_new(:is_leader, fn -> false end) + + ~H""" +
  • +
    +
    +
    +

    + <%= @event.name %> +

    +
    + <%= if NaiveDateTime.compare(@current_time, @event.started_at) == :gt and NaiveDateTime.compare(@current_time, @event.expired_at) == :lt do %> +

    + <%= gettext "In progress" %> +

    + <% end %> + <%= if NaiveDateTime.compare(@current_time, @event.started_at) == :lt do %> +

    + <%= gettext "Incoming" %> +

    + <% end %> + <%= if NaiveDateTime.compare(@current_time, @event.expired_at) == :gt do %> +

    + <%= gettext "Finished" %> +

    + <% end %> +
    +
    +
    +
    + +

    + <%= @event.code %> +

    +
    +
    + + <%= if NaiveDateTime.compare(@current_time, @event.started_at) == :gt and NaiveDateTime.compare(@current_time, @event.expired_at) == :lt do %> +

    + <%= gettext "Finish on" %> +

    + <% end %> + <%= if NaiveDateTime.compare(@current_time, @event.started_at) == :lt do %> +

    + <%= gettext "Starting on" %> +

    + <% end %> + <%= if NaiveDateTime.compare(@current_time, @event.expired_at) == :gt do %> +

    + <%= gettext "Finished on" %> +

    + <% end %> +
    +
    + + <%= if @event.presentation_file.status == "fail" && @event.presentation_file.hash do %> +

    <%= gettext("Error when processing the new file") %>

    + <% end %> + + <%= if NaiveDateTime.compare(@current_time, @event.expired_at) == :lt do %> + <%= if @event.presentation_file.status == "done" || (@event.presentation_file.status == "fail" && @event.presentation_file.hash) do %> + + <% end %> + + <%= if @event.presentation_file.status == "fail" && is_nil(@event.presentation_file.hash) do %> +
    + <%= gettext("Error when processing the file") %> +
    + <%= if not @is_leader do %> + + <%= gettext "Edit" %> + + <% end %> +
    +
    + <% end %> + + <%= if @event.presentation_file.status == "progress" do %> +
    + + <%= gettext("Processing your file...") %> +
    + <% end %> + + <% end %> + + <%= if NaiveDateTime.compare(@current_time, @event.expired_at) == :gt do %> +
    + +
    + <%= if not @is_leader do %> + <%= link gettext("Delete"), to: "#", phx_click: "delete", phx_value_id: @event.uuid, data: [confirm: gettext("This will delete all data related to your event, this cannot be undone. Confirm ?")], class: "flex w-full lg:w-auto text-white rounded-md tracking-wide focus:outline-none focus:shadow-outline text-red-500 text-sm items-center" %> + <% end %> +
    +
    + <% end %> + +
    +
    +
  • + """ + end +end diff --git a/lib/claper_web/live/event_live/form_component.ex b/lib/claper_web/live/event_live/form_component.ex new file mode 100644 index 0000000..59e2135 --- /dev/null +++ b/lib/claper_web/live/event_live/form_component.ex @@ -0,0 +1,221 @@ +defmodule ClaperWeb.EventLive.FormComponent do + use ClaperWeb, :live_component + + alias Claper.Events + + @impl true + def update(%{event: event} = assigns, socket) do + changeset = Events.change_event(event) + + {:ok, + socket + |> assign(assigns) + |> assign(:changeset, changeset) + |> allow_upload(:presentation_file, + accept: ~w(.pdf .ppt .pptx), + auto_upload: true, + max_entries: 1, + max_file_size: 15_000_000 + )} + end + + @impl true + def handle_event("validate", %{"event" => event_params}, socket) do + changeset = + socket.assigns.event + |> Events.change_event(event_params) + |> Map.put(:action, :validate) + + {:noreply, socket |> assign(:changeset, changeset)} + end + + @impl true + def handle_event("validate-file", _params, socket) do + {:noreply, socket} + end + + @impl true + def handle_event("remove-file", %{"ref" => ref}, socket) do + {:noreply, cancel_upload(socket, :presentation_file, ref)} + end + + @impl true + def handle_event("save", %{"event" => event_params}, socket) do + save_event(socket, socket.assigns.action, event_params) + end + + @impl true + def handle_event( + "add-leader", + _params, + socket + ) do + existing_leaders = + Map.get(socket.assigns.changeset.changes, :leaders, socket.assigns.event.leaders) + + leaders = + existing_leaders + |> Enum.concat([ + Events.change_activity_leader(%Events.ActivityLeader{temp_id: get_temp_id()}) + ]) + + changeset = + socket.assigns.changeset + |> Ecto.Changeset.put_assoc(:leaders, leaders) + + {:noreply, assign(socket, changeset: changeset)} + end + + @impl true + def handle_event( + "remove-leader", + %{"remove" => remove_id}, + socket + ) do + leaders = + socket.assigns.changeset.changes.leaders + |> Enum.reject(fn %{data: leader} -> + leader.temp_id == remove_id + end) + + IO.inspect(leaders) + + changeset = + socket.assigns.changeset + |> Ecto.Changeset.put_assoc(:leaders, leaders) + + {:noreply, assign(socket, changeset: changeset)} + end + + defp get_temp_id, do: :crypto.strong_rand_bytes(5) |> Base.url_encode64() |> binary_part(0, 5) + + defp save_file(socket, %{"code" => code, "name" => name} = event_params, after_save) do + hash = :erlang.phash2("#{code}-#{name}") + + static_path = + Path.join([ + :code.priv_dir(:claper), + "static", + "uploads", + "#{hash}" + ]) + + case uploaded_entries(socket, :presentation_file) do + {[_ | _], []} -> + [dest | _] = + consume_uploaded_entries(socket, :presentation_file, fn %{path: path}, entry -> + [ext | _] = MIME.extensions(entry.client_type) + + dest = + Path.join([ + static_path, + "original.#{ext}" + ]) + + # The `static/uploads` directory must exist for `File.cp!/2` to work. + File.mkdir_p!(static_path) + + File.cp!(path, dest) + + {:ok, Routes.static_path(socket, "/uploads/#{hash}/#{Path.basename(dest)}")} + end) + + [ext | _] = MIME.extensions(MIME.from_path(dest)) + + if !Map.has_key?(socket.assigns.event.presentation_file, :id) do + after_save.( + socket, + Map.put(event_params, "presentation_file", %{ + "status" => "progress", + "presentation_state" => %{} + }), + hash, + ext + ) + else + after_save.( + socket, + event_params, + hash, + ext + ) + end + + _ -> + after_save.(socket, event_params, nil, nil) + end + end + + defp save_event(socket, :edit, event_params) do + save_file(socket, event_params, &edit_event/4) + end + + defp save_event(socket, :new, event_params) do + save_file(socket, event_params, &create_event/4) + end + + defp create_event(socket, event_params, hash, ext) do + IO.inspect( + event_params + |> Map.put("user_id", socket.assigns.current_user.id) + ) + + case Events.create_event( + event_params + |> Map.put("user_id", socket.assigns.current_user.id) + ) do + {:ok, event} -> + with e <- Events.get_event!(event.uuid, [:presentation_file]) do + Task.Supervisor.async_nolink(Claper.TaskSupervisor, fn -> + Claper.Tasks.Converter.convert( + socket.assigns.current_user.id, + "original.#{ext}", + hash, + ext, + e.presentation_file.id + ) + end) + end + + {:noreply, + socket + |> put_flash(:info, gettext("Created successfully")) + |> push_redirect(to: socket.assigns.return_to)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, changeset: changeset)} + end + end + + defp edit_event(socket, event_params, hash, ext) do + case Events.update_event( + socket.assigns.event, + event_params + ) do + {:ok, _event} -> + if !is_nil(hash) && !is_nil(ext) do + Task.Supervisor.async_nolink(Claper.TaskSupervisor, fn -> + Claper.Tasks.Converter.convert( + socket.assigns.current_user.id, + "original.#{ext}", + hash, + ext, + socket.assigns.event.presentation_file.id + ) + end) + end + + {:noreply, + socket + |> put_flash(:info, gettext("Updated successfully")) + |> push_redirect(to: socket.assigns.return_to)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, :changeset, changeset)} + end + end + + def error_to_string(:too_large), do: gettext("Your file is too large") + def error_to_string(:not_accepted), do: gettext("You have selected an incorrect file type") + def error_to_string(:external_client_failure), do: gettext("Upload failed") +end diff --git a/lib/claper_web/live/event_live/form_component.html.heex b/lib/claper_web/live/event_live/form_component.html.heex new file mode 100644 index 0000000..1983687 --- /dev/null +++ b/lib/claper_web/live/event_live/form_component.html.heex @@ -0,0 +1,227 @@ +
    +
    +
    +

    + <%= @page_title %> +

    +
    +
    + <%= if @uploads.presentation_file.entries |> Enum.at(0, %{}) |> Map.get(:progress, 0) >= 100 || Map.has_key?(@event.presentation_file, :id) do %> + + <% else %> +
    + <%= case @action do + :edit -> gettext("Save") + :new -> gettext("Create") + end %> +
    + <% end %> + <%= if @action == :edit && NaiveDateTime.compare(NaiveDateTime.utc_now(), @event.expired_at) == :lt do %> + <%= link gettext("Delete"), to: "#", phx_click: "delete", phx_value_id: @event.uuid, data: [confirm: gettext("Are you sure?")], class: "w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500" %> + <% end %> +
    +
    + + <%= if Map.get(@event, :presentation_file) == nil || Map.get(@event.presentation_file, :id) == nil do %> +
    + +
    + + <%= if @uploads.presentation_file.entries |> Enum.at(0, %{}) |> Map.get(:progress, 0) < 100 do %> +
    + +
    + +

    <%= gettext "or drag and drop" %>

    +
    +

    <%= gettext "PDF, PPT, PPTX up to 15MB" %>

    + <%= for entry <- @uploads.presentation_file.entries do %> + <%= entry.progress %> + <%= for err <- upload_errors(@uploads.presentation_file, entry) do %> +

    <%= error_to_string(err) %>

    + <% end %> + <% end %> +
    + <% else %> + <%= for entry <- @uploads.presentation_file.entries do %> +
    + + + +

    <%= gettext "Presentation uploaded" %>

    +
    +

    <%= entry.client_name %>

    +

    + <%= gettext "Remove" %> +

    + <% end %> + + <% end %> + +
    +
    + <% else %> +
    + +
    + <%= if @uploads.presentation_file.entries |> Enum.at(0, %{}) |> Map.get(:progress, 0) < 100 do %> +
    + + + +

    <%= gettext "Presentation attached" %>

    +
    +
    + + +
    + <% else %> +
    + + + +

    <%= gettext "Presentation replaced" %>

    +
    + + <%= for entry <- @uploads.presentation_file.entries do %> +

    <%= entry.client_name %>

    +

    + <%= gettext "Remove" %> +

    + <% end %> + <% end %> + + <%= for entry <- @uploads.presentation_file.entries do %> + <%= for err <- upload_errors(@uploads.presentation_file, entry) do %> +

    <%= error_to_string(err) %>

    + <% end %> + <% end %> +
    +
    + <% end %> + + <.form + let={f} + for={@changeset} + id="event-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save"> + +
    +
    + +
    + +
    + +
    + +
    + +
    + + <%= if @action == :edit do %> + + <% end %> +
    + +
    + <%= gettext("Facilitators can present and manage interactions") %> + +
    + + <%= inputs_for f, :leaders, fn l -> %> + +
    + String.downcase() |> String.trim() ) |> Base.encode16() |> String.downcase()}"} alt=""> + + <%= if is_nil(l.data.temp_id) do %> +
    + +
    + + + + + <% else %> +
    + +
    + + <%= hidden_input l, :temp_id %> + <%= hidden_input l, :event_id, value: @event.id %> + + + <% end %> + + +
    + <% end %> + + + +
    diff --git a/lib/claper_web/live/event_live/index.ex b/lib/claper_web/live/event_live/index.ex new file mode 100644 index 0000000..f31ae11 --- /dev/null +++ b/lib/claper_web/live/event_live/index.ex @@ -0,0 +1,94 @@ +defmodule ClaperWeb.EventLive.Index do + use ClaperWeb, :live_view + + alias Claper.Events + alias Claper.Events.Event + + on_mount(ClaperWeb.UserLiveAuth) + + @impl true + def mount(_params, session, socket) do + with %{"locale" => locale} <- session do + Gettext.put_locale(ClaperWeb.Gettext, locale) + end + + if connected?(socket) do + Phoenix.PubSub.subscribe(Claper.PubSub, "events:#{socket.assigns.current_user.id}") + end + + socket = + socket + |> assign(:events, list_events(socket)) + |> assign(:managed_events, list_managed_events(socket)) + + {:ok, socket, temporary_assigns: [events: []]} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + @impl true + def handle_info({:presentation_file_process_done, presentation}, socket) do + event = Claper.Events.get_event!(presentation.event.uuid, [:presentation_file]) + + {:noreply, + socket |> update(:events, fn events -> [event | events] end) |> put_flash(:info, nil)} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + event = Events.get_event!(id, [:presentation_file]) + {:ok, _} = Events.delete_event(event) + + Task.Supervisor.async_nolink(Claper.TaskSupervisor, fn -> + Claper.Tasks.Converter.clear(event.presentation_file.hash) + end) + + {:noreply, redirect(socket, to: Routes.event_index_path(socket, :index))} + end + + defp apply_action(socket, :edit, %{"id" => id}) do + event = + Events.get_user_event!(socket.assigns.current_user.id, id, [:presentation_file, :leaders]) + + if event.presentation_file.status == "fail" && event.presentation_file.hash do + Claper.Presentations.update_presentation_file(event.presentation_file, %{ + "status" => "done" + }) + end + + {:ok, socket |> assign(:event, event)} + + socket + |> assign(:page_title, gettext("Edit")) + |> assign(:event, event) + end + + defp apply_action(socket, :new, _params) do + socket + |> assign(:page_title, gettext("Create")) + |> assign(:event, %Event{ + started_at: NaiveDateTime.utc_now(), + expired_at: NaiveDateTime.utc_now() |> NaiveDateTime.add(3600 * 2, :second), + code: Enum.random(1000..9999), + leaders: [] + }) + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, gettext("Dashboard")) + |> assign(:event, nil) + end + + defp list_events(socket) do + Events.list_events(socket.assigns.current_user.id, [:presentation_file]) + end + + defp list_managed_events(socket) do + Events.list_managed_events_by(socket.assigns.current_user.email, [:presentation_file]) + end + +end diff --git a/lib/claper_web/live/event_live/index.html.heex b/lib/claper_web/live/event_live/index.html.heex new file mode 100644 index 0000000..94c057a --- /dev/null +++ b/lib/claper_web/live/event_live/index.html.heex @@ -0,0 +1,64 @@ +
    + <%= if @live_action in [:new, :edit] do %> + <.live_component module={ClaperWeb.EventLive.FormComponent} id="event-create" event={@event} page_title={@page_title} action={@live_action} return_to={Routes.event_index_path(@socket, :index)} current_user={@current_user} /> + <% else %> + + +
    +
    +

    + <%= gettext("My presentations") %> +

    +
    + +
    + +
    +
      + <% current_time = NaiveDateTime.utc_now() %> + <%= for event <- @events do %> + <.live_component module={ClaperWeb.EventLive.EventCardComponent} id={"event-#{event.uuid}"} event={event} current_time={current_time} /> + <% end %> +
    + <%= if Enum.count(@events) == 0 do %> +
    + +

    <%= gettext("Create your first presentation") %>

    +
    + <% end %> +
    + + <%= if length(@managed_events) > 0 do %> + +
    +
    +

    + <%= gettext("Invited presentations") %> +

    +
    +
    + +
    +
      + <% current_time = NaiveDateTime.utc_now() %> + <%= for event <- @managed_events do %> + <.live_component module={ClaperWeb.EventLive.EventCardComponent} id={"managed-event-#{event.uuid}"} is_leader={true} event={event} current_time={current_time} /> + <% end %> +
    +
    + <% end %> + <% end %> +
    \ No newline at end of file diff --git a/lib/claper_web/live/event_live/join.ex b/lib/claper_web/live/event_live/join.ex new file mode 100644 index 0000000..3d6f408 --- /dev/null +++ b/lib/claper_web/live/event_live/join.ex @@ -0,0 +1,45 @@ +defmodule ClaperWeb.EventLive.Join do + use ClaperWeb, :live_view + + on_mount(ClaperWeb.AttendeeLiveAuth) + + @impl true + def mount(params, session, socket) do + with %{"locale" => locale} <- session do + Gettext.put_locale(ClaperWeb.Gettext, locale) + end + + if params["disconnected_from"] do + try do + event = Claper.Events.get_event!(params["disconnected_from"]) + {:ok, socket |> assign(:last_event, event)} + rescue + _ -> {:ok, socket |> assign(:last_event, nil)} + end + else + {:ok, socket |> assign(:last_event, nil)} + end + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + @impl true + def handle_event("join", %{"event" => %{"code" => code}}, socket) do + {:noreply, + socket |> push_redirect(to: Routes.event_show_path(socket, :show, String.downcase(code)))} + end + + defp apply_action(socket, :join, _params) do + socket + |> redirect(to: "/") + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, gettext("Join")) + |> assign(:event, nil) + end +end diff --git a/lib/claper_web/live/event_live/join.html.heex b/lib/claper_web/live/event_live/join.html.heex new file mode 100644 index 0000000..1eece33 --- /dev/null +++ b/lib/claper_web/live/event_live/join.html.heex @@ -0,0 +1,70 @@ + + +
    + +
    +
    + <%= gettext("About") %> + <%= if @current_user do %> + <%= live_patch gettext("Dashboard"), to: Routes.event_index_path(@socket, :index), class: "relative inline-flex items-center px-4 py-1 text-base font-sm rounded-md text-white bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500" %> + <% else %> + <%= live_patch gettext("Login"), to: Routes.user_session_path(@socket, :new), class: "relative inline-flex items-center px-4 py-1 text-base font-sm rounded-md text-white bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500" %> + <% end %> +
    + + +
    + +
    + +
    + +
    + + <%= if @last_event do %> + <%= live_patch to: Routes.event_show_path(@socket, :show, @last_event.code) do %> +
    +
    +

    + <%= gettext "Return to your last presentation" %> (#<%= @last_event.code %>) +

    +

    + +

    +
    +
    + <% end %> + <% end %> + + <%= form_for :event, Routes.event_join_path(@socket, :join), ["phx-submit": "join", "phx-hook": "JoinEvent", id: "form"], fn f -> %> +
    + <%= text_input f, :code, required: true, autofocus: true, id: "input", class: "transition-all bg-transparent tracking-widest w-full uppercase text-white text-2xl px-3 border-b border-gray-200 focus:border-b-2 pt-5 pl-12 pb-3 outline-none" %> + code +
    + +
    + + +
    + + <% end %> + +
    +
    \ No newline at end of file diff --git a/lib/claper_web/live/event_live/manage.ex b/lib/claper_web/live/event_live/manage.ex new file mode 100644 index 0000000..4b87bbf --- /dev/null +++ b/lib/claper_web/live/event_live/manage.ex @@ -0,0 +1,343 @@ +defmodule ClaperWeb.EventLive.Manage do + use ClaperWeb, :live_view + + alias ClaperWeb.Presence + alias Claper.Polls + + @impl true + def mount(%{"code" => code}, session, socket) do + with %{"locale" => locale} <- session do + Gettext.put_locale(ClaperWeb.Gettext, locale) + end + + event = + Claper.Events.get_event_with_code(code, [ + :user, + presentation_file: [:polls, :presentation_state] + ]) + + if is_nil(event) || not is_leader(socket, event) do + {:ok, + socket + |> put_flash(:error, gettext("Presentation doesn't exist")) + |> redirect(to: "/")} + else + if connected?(socket) do + Claper.Events.Event.subscribe(event.uuid) + # Claper.Presentations.subscribe(event.presentation_file.id) + + Presence.track( + self(), + "event:#{event.uuid}", + socket.assigns.current_user.id, + %{} + ) + end + + socket = + socket + |> assign(:attendees_nb, 1) + |> assign(:event, event) + |> assign(:state, event.presentation_file.presentation_state) + |> assign(:posts, list_posts(socket, event.uuid)) + |> assign(:polls, list_polls(socket, event.presentation_file.id)) + |> assign(:create, nil) + |> assign(:create_action, :new) + |> push_event("page-manage", %{ + current_page: event.presentation_file.presentation_state.position, + timeout: 500 + }) + |> poll_at_position(false) + + {:ok, socket, temporary_assigns: [posts: []]} + end + end + + defp is_leader(%{assigns: %{current_user: current_user}} = _socket, event) do + Claper.Events.is_leaded_by(current_user.email, event) || event.user.id == current_user.id + end + + defp is_leader(_socket, _event), do: false + + @impl true + def handle_info(%{event: "presence_diff"}, %{assigns: %{event: event}} = socket) do + attendees = Presence.list("event:#{event.uuid}") + {:noreply, assign(socket, :attendees_nb, Enum.count(attendees))} + end + + @impl true + def handle_info({:post_created, post}, socket) do + {:noreply, + socket |> update(:posts, fn posts -> [post | posts] end) |> push_event("scroll", %{})} + end + + @impl true + def handle_info({:post_updated, post}, socket) do + {:noreply, socket |> update(:posts, fn posts -> [post | posts] end)} + end + + @impl true + def handle_info({:reaction_added, post}, socket) do + {:noreply, socket |> update(:posts, fn posts -> [post | posts] end)} + end + + @impl true + def handle_info({:reaction_removed, post}, socket) do + {:noreply, socket |> update(:posts, fn posts -> [post | posts] end)} + end + + @impl true + def handle_info({:post_deleted, post}, socket) do + {:noreply, socket |> update(:posts, fn posts -> [post | posts] end)} + end + + @impl true + def handle_info({:poll_updated, poll}, socket) do + {:noreply, + socket + |> update(:current_poll, fn _current_poll -> poll end)} + end + + @impl true + def handle_info( + {:current_poll, poll}, + socket + ) do + {:noreply, socket |> assign(:current_poll, poll)} + end + + @impl true + def handle_info(_, socket) do + {:noreply, socket} + end + + @impl true + def handle_event( + "current-page", + %{"page" => page}, + %{assigns: %{state: state}} = socket + ) do + page = String.to_integer(page) + + {:ok, new_state} = + Claper.Presentations.update_presentation_state( + state, + %{ + :position => page + } + ) + + Phoenix.PubSub.broadcast( + Claper.PubSub, + "event:#{socket.assigns.event.uuid}", + {:page_changed, page} + ) + + {:noreply, + socket + |> assign(:state, new_state) + |> poll_at_position} + end + + def handle_event("poll-set-default", %{"id" => id}, socket) do + Polls.set_default( + id, + socket.assigns.event.presentation_file.id, + socket.assigns.state.position + ) + + poll = Polls.get_poll!(id) + + Phoenix.PubSub.broadcast( + Claper.PubSub, + "event:#{socket.assigns.event.uuid}", + {:current_poll, poll} + ) + + {:noreply, + socket + |> assign(:polls, list_polls(socket, socket.assigns.event.presentation_file.id))} + end + + @impl true + def handle_event( + "ban", + %{"attendee-identifier" => attendee_identifier}, + %{assigns: %{event: event}} = socket + ) do + Claper.Posts.delete_all_posts(:attendee_identifier, attendee_identifier, event) + + ban(attendee_identifier, socket) + end + + @impl true + def handle_event( + "ban", + %{"user-id" => user_id}, + %{assigns: %{event: event}} = socket + ) do + Claper.Posts.delete_all_posts(:user_id, user_id, event) + + ban(String.to_integer(user_id), socket) + end + + @impl true + def handle_event( + "checked", + %{"key" => "chat_visible", "value" => value}, + %{assigns: %{state: state}} = socket + ) do + {:ok, new_state} = + Claper.Presentations.update_presentation_state( + state, + %{ + :chat_visible => value + } + ) + + {:noreply, socket |> assign(:state, new_state)} + end + + @impl true + def handle_event( + "checked", + %{"key" => "poll_visible", "value" => value}, + %{assigns: %{state: state}} = socket + ) do + {:ok, new_state} = + Claper.Presentations.update_presentation_state( + state, + %{ + :poll_visible => value + } + ) + + {:noreply, socket |> assign(:state, new_state)} + end + + @impl true + def handle_event( + "checked", + %{"key" => "join_screen_visible", "value" => value}, + %{assigns: %{state: state}} = socket + ) do + {:ok, new_state} = + Claper.Presentations.update_presentation_state( + state, + %{ + :join_screen_visible => value + } + ) + + {:noreply, socket |> assign(:state, new_state)} + end + + @impl true + def handle_event("delete", %{"event-id" => event_id, "id" => id}, socket) do + post = Claper.Posts.get_post!(id, [:event]) + {:ok, _} = Claper.Posts.delete_post(post) + + {:noreply, assign(socket, :posts, list_posts(socket, event_id))} + end + + @impl true + def handle_event("maybe-redirect", _params, socket) do + if socket.assigns.create != nil do + {:noreply, + socket + |> push_redirect(to: Routes.event_manage_path(socket, :show, socket.assigns.event.code))} + else + {:noreply, socket} + end + end + + @impl true + def handle_event("delete-poll", %{"id" => id}, socket) do + poll = Polls.get_poll!(id) + {:ok, _} = Polls.delete_poll(socket.assigns.event.uuid, poll) + + {:noreply, + socket + |> assign(:polls, list_polls(socket, socket.assigns.event.presentation_file.id))} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + def toggle_add_modal(js \\ %JS{}) do + js + |> JS.toggle( + to: "#add-modal", + out: "animate__animated animate__fadeOut", + in: "animate__animated animate__fadeIn" + ) + |> JS.push("maybe-redirect", target: "#add-modal") + end + + defp apply_action(socket, :show, _params) do + socket + end + + defp apply_action(socket, :add_poll, _params) do + socket + |> assign(:create, "poll") + |> assign(:poll, %Polls.Poll{ + poll_opts: [%Polls.PollOpt{id: 0}, %Polls.PollOpt{id: 1}] + }) + end + + defp apply_action(socket, :edit_poll, %{"id" => id}) do + poll = Polls.get_poll!(id) + + socket + |> assign(:create, "poll") + |> assign(:create_action, :edit) + |> assign(:poll, poll) + end + + defp poll_at_position( + %{assigns: %{event: event, state: state}} = socket, + broadcast \\ true + ) do + with poll <- + Claper.Polls.get_poll_current_position( + event.presentation_file.id, + state.position + ) do + if broadcast do + Phoenix.PubSub.broadcast( + Claper.PubSub, + "event:#{event.uuid}", + {:current_poll, poll} + ) + end + + socket |> assign(:current_poll, poll) + end + end + + defp ban(user, %{assigns: %{event: event, state: state}} = socket) do + {:ok, new_state} = + Claper.Presentations.update_presentation_state(state, %{ + "banned" => state.banned ++ ["#{user}"] + }) + + Phoenix.PubSub.broadcast( + Claper.PubSub, + "event:#{event.uuid}", + {:banned, user} + ) + + {:noreply, socket |> assign(:state, new_state)} + end + + defp list_posts(_socket, event_id) do + Claper.Posts.list_posts(event_id, [:event, :reactions]) + end + + defp list_polls(_socket, presentation_file_id) do + Claper.Polls.list_polls(presentation_file_id) + end +end diff --git a/lib/claper_web/live/event_live/manage.html.heex b/lib/claper_web/live/event_live/manage.html.heex new file mode 100644 index 0000000..445c66c --- /dev/null +++ b/lib/claper_web/live/event_live/manage.html.heex @@ -0,0 +1,255 @@ +
    +
    +
    +
    + + + + + +

    <%= @event.name %>

    +
    + +
    + +
    + + + + +
    + +
    + +
    + <%= for index <- 0..@event.presentation_file.length-1 do %> + + <%= if @state.position == index && @state.position > 0 do %> + + <% end %> + +
    + + +
    + <%= for poll <- Enum.filter(@polls, fn poll -> poll.position == index end) do %> +
    +
    + + + +
    +
    + <%= gettext "Poll" %>: <%= poll.title %> + <%= if @state.position == index do %> + <%= if poll.enabled do %> + + + + + <%= gettext "Active" %> + + <% else %> + + <% end %> + + + + + + <% end %> +
    +
    +
    + <% end %> +
    + + + +
    + + <%= if @state.position == index && @state.position < @event.presentation_file.length - 1 do %> + + <% end %> + <% end %> +
    + +
    + +
    +
    + <%= if Enum.count(@posts) == 0 do %> +
    + + + + +

    <%= gettext "Messages from attendees will appear here." %>

    +
    + <% end %> +
    0, do: 'max-h-full'} pb-5 px-5"} phx-update="append" data-posts-nb={Enum.count(@posts)} phx-hook="ScrollIntoDiv" data-target="#post-list"> + <%= for post <- @posts do %> +
    +
    + +
    + <%= if post.attendee_identifier do %> + <%= link gettext("Ban"), to: "#", phx_click: "ban", phx_value_attendee_identifier: post.attendee_identifier, data: [confirm: gettext("Blocking this user will delete all his messages and he will not be able to join again, confirm ?")] %> / + <% else %> + <%= link gettext("Ban"), to: "#", phx_click: "ban", phx_value_user_id: post.user_id, data: [confirm: gettext("Blocking this user will delete all his messages and he will not be able to join again, confirm ?")] %> / + <% end %> + <%= link gettext("Delete"), to: "#", phx_click: "delete", phx_value_id: post.uuid, phx_value_event_id: @event.uuid %> +
    + +
    + <%= if post.attendee_identifier do %> + + <% else %> + + <% end %> + +

    <%= post.body %>

    +
    + + + <%= if post.like_count > 0 || post.love_count > 0 || post.lol_count > 0 do %> +
    +
    + <%= if post.like_count > 0 do %> + + <%= post.like_count %> + <% end %> +
    +
    + <%= if post.love_count > 0 do %> + + <%= post.love_count %> + <% end %> +
    +
    + <%= if post.lol_count > 0 do %> + + <%= post.lol_count %> + <% end %> +
    +
    + <% end %> +
    +
    + <% end %> +
    +
    + +
    +
    + <%= gettext "On screen settings" %> + +
    + + <%= gettext "Instructions" %> +
    + +
    + + <%= gettext "Messages" %> +
    + +
    + + <%= gettext "Active poll results" %> +
    + +
    +
    + +
    +
    +
    \ No newline at end of file diff --git a/lib/claper_web/live/event_live/poll_component.ex b/lib/claper_web/live/event_live/poll_component.ex new file mode 100644 index 0000000..bc4adbc --- /dev/null +++ b/lib/claper_web/live/event_live/poll_component.ex @@ -0,0 +1,91 @@ +defmodule ClaperWeb.EventLive.PollComponent do + use ClaperWeb, :live_component + + @impl true + def render(assigns) do + ~H""" +
    + +
    + +
    +
    + + + +
    + +

    <%= gettext "Current poll" %>

    +

    <%= @poll.title %>

    + +
    +
    +
    + <%= if (length @poll.poll_opts) > 0 do %> + <%= for {opt, idx} <- Enum.with_index(@poll.poll_opts) do %> + <%= if ! is_nil(@current_poll_vote) do %> + + <% else %> + + <% end %> + <% end %> + <% end %> +
    + + <%= if ! is_nil(@current_poll_vote) do %> + + <% else %> + + <% end %> +
    +
    +
    + """ + end + + def toggle_poll(js \\ %JS{}) do + js + |> JS.toggle( + out: "animate__animated animate__zoomOut", + in: "animate__animated animate__zoomIn", + to: "#collapsed-poll", + time: 50 + ) + |> JS.toggle( + out: "animate__animated animate__zoomOut", + in: "animate__animated animate__zoomIn", + to: "#extended-poll" + ) + end +end diff --git a/lib/claper_web/live/event_live/post_component.ex b/lib/claper_web/live/event_live/post_component.ex new file mode 100644 index 0000000..31aa2fe --- /dev/null +++ b/lib/claper_web/live/event_live/post_component.ex @@ -0,0 +1,120 @@ +defmodule ClaperWeb.EventLive.PostComponent do + use ClaperWeb, :live_component + + def render(assigns) do + ~H""" +
    + <%= if @post.attendee_identifier == @attendee_identifier || (not is_nil(@current_user) && @post.user_id == @current_user.id) do %> +
    + + +

    <%= @post.body %>

    + +
    + <%= if @post.like_count > 0 do %> +
    + + <%= @post.like_count %> +
    + <% end %> + <%= if @post.love_count > 0 do %> +
    + + <%= @post.love_count %> +
    + <% end %> + <%= if @post.lol_count > 0 do %> +
    + + <%= @post.lol_count %> +
    + <% end %> +
    +
    + <% else %> +
    + <%= if is_a_leader(@post, @event, @leaders) do %> +
    + + <%= gettext "Host" %> +
    + <% end %> + + <%= if @is_leader do %> + + + <% end %> + +

    <%= @post.body %>

    + +
    + <%= if not Enum.member?(@liked_posts, @post.id) do %> + + <% else %> + + <% end %> + <%= if not Enum.member?(@loved_posts, @post.id) do %> + + <% else %> + + <% end %> + <%= if not Enum.member?(@loled_posts, @post.id) do %> + + <% else %> + + <% end %> +
    +
    + <% end %> +
    + """ + end + + defp is_a_leader(post, event, leaders) do + !is_nil(post.user_id) && + (post.user_id == event.user_id || + Enum.any?(leaders, fn leader -> + leader.user_id == post.user_id + end)) + end +end diff --git a/lib/claper_web/live/event_live/presenter.ex b/lib/claper_web/live/event_live/presenter.ex new file mode 100644 index 0000000..14fd6f3 --- /dev/null +++ b/lib/claper_web/live/event_live/presenter.ex @@ -0,0 +1,178 @@ +defmodule ClaperWeb.EventLive.Presenter do + use ClaperWeb, :live_view + + alias ClaperWeb.Presence + + @impl true + def mount(%{"code" => code}, session, socket) do + with %{"locale" => locale} <- session do + Gettext.put_locale(ClaperWeb.Gettext, locale) + end + + event = + Claper.Events.get_event_with_code(code, [ + :user, + presentation_file: [:polls, :presentation_state] + ]) + + if is_nil(event) || not is_leader(socket, event) do + {:ok, + socket + |> put_flash(:error, gettext("Presentation doesn't exist")) + |> redirect(to: "/")} + else + if connected?(socket) do + Claper.Events.Event.subscribe(event.uuid) + Claper.Presentations.subscribe(event.presentation_file.id) + + Presence.track( + self(), + "event:#{event.uuid}", + socket.assigns.current_user.id, + %{} + ) + end + + socket = + socket + |> assign(:attendees_nb, 1) + |> assign(:event, event) + |> assign(:state, event.presentation_file.presentation_state) + |> assign(:posts, list_posts(socket, event.uuid)) + |> assign(:reacts, []) + |> poll_at_position + + {:ok, socket, temporary_assigns: [posts: []]} + end + end + + defp is_leader(%{assigns: %{current_user: current_user}} = _socket, event) do + Claper.Events.is_leaded_by(current_user.email, event) || event.user.id == current_user.id + end + + defp is_leader(_socket, _event), do: false + + @impl true + def handle_info(%{event: "presence_diff"}, %{assigns: %{event: event}} = socket) do + attendees = Presence.list("event:#{event.uuid}") + {:noreply, assign(socket, :attendees_nb, Enum.count(attendees))} + end + + @impl true + def handle_info({:post_created, post}, socket) do + {:noreply, + socket + |> update(:posts, fn posts -> [post | posts] end)} + end + + @impl true + def handle_info({:state_updated, state}, socket) do + {:noreply, + socket + |> assign(:state, state) + |> push_event("page", %{current_page: state.position}) + |> push_event("reset-global-react", %{}) + |> poll_at_position} + end + + @impl true + def handle_info({:post_updated, post}, socket) do + {:noreply, socket |> update(:posts, fn posts -> [post | posts] end)} + end + + @impl true + def handle_info({:reaction_added, post}, socket) do + {:noreply, socket |> update(:posts, fn posts -> [post | posts] end)} + end + + @impl true + def handle_info({:reaction_removed, post}, socket) do + {:noreply, socket |> update(:posts, fn posts -> [post | posts] end)} + end + + @impl true + def handle_info({:post_deleted, post}, socket) do + {:noreply, socket |> update(:posts, fn posts -> [post | posts] end)} + end + + @impl true + def handle_info({:poll_updated, poll}, socket) do + {:noreply, + socket + |> update(:current_poll, fn _current_poll -> poll end)} + end + + @impl true + def handle_info({:poll_deleted, _poll}, socket) do + {:noreply, + socket + |> update(:current_poll, fn _current_poll -> nil end)} + end + + @impl true + def handle_info({:chat_visible, value}, socket) do + {:noreply, + socket + |> push_event("chat-visible", %{value: value}) + |> update(:chat_visible, fn _chat_visible -> value end)} + end + + @impl true + def handle_info({:poll_visible, value}, socket) do + {:noreply, + socket + |> push_event("poll-visible", %{value: value}) + |> update(:poll_visible, fn _poll_visible -> value end)} + end + + @impl true + def handle_info({:join_screen_visible, value}, socket) do + {:noreply, + socket + |> push_event("join-screen-visible", %{value: value}) + |> update(:join_screen_visible, fn _join_screen_visible -> value end)} + end + + @impl true + def handle_info({:react, type}, socket) do + {:noreply, + socket + |> push_event("global-react", %{type: type})} + end + + @impl true + def handle_info( + {:current_poll, poll}, + socket + ) do + {:noreply, socket |> assign(:current_poll, poll)} + end + + @impl true + def handle_info(_, socket) do + {:noreply, socket} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :show, _params) do + socket + end + + defp poll_at_position(%{assigns: %{event: event, state: state}} = socket) do + with poll <- + Claper.Polls.get_poll_current_position( + event.presentation_file.id, + state.position + ) do + socket |> assign(:current_poll, poll) + end + end + + defp list_posts(_socket, event_id) do + Claper.Posts.list_posts(event_id, [:event, :reactions]) + end +end diff --git a/lib/claper_web/live/event_live/presenter.html.heex b/lib/claper_web/live/event_live/presenter.html.heex new file mode 100644 index 0000000..d12de8d --- /dev/null +++ b/lib/claper_web/live/event_live/presenter.html.heex @@ -0,0 +1,103 @@ + + +
    + + +
    +
    + <%= gettext "Scan to interact in real-time" %> +
    +
    + <%= gettext "Or go to Claper.co and use the code:" %> + #<%= String.upcase(@event.code) %> +
    +
    + + + <%= if @current_poll do %> +
    +
    +

    <%= @current_poll.title %>

    + +
    + <%= if (length @current_poll.poll_opts) > 0 do %> + <%= for opt <- @current_poll.poll_opts do %> +
    +
    +
    + <%= opt.content %> +
    + <%= opt.percentage %>% (<%= opt.vote_count %>) +
    + <% end %> + <% end %> +
    +
    +
    + + <% end %> + + +
    +
    + <%= for post <- @posts do %> +
    +
    + +

    <%= post.body %>

    + + <%= if post.like_count > 0 || post.love_count > 0 || post.lol_count > 0 do %> +
    +
    + <%= if post.like_count > 0 do %> + + <%= post.like_count %> + <% end %> +
    +
    + <%= if post.love_count > 0 do %> + + <%= post.love_count %> + <% end %> +
    +
    + <%= if post.lol_count > 0 do %> + + <%= post.lol_count %> + <% end %> +
    +
    + <% end %> +
    +
    + <% end %> +
    + + +
    + <%= for index <- 1..@event.presentation_file.length do %> + <%= if System.get_env("PRESENTATION_STORAGE") == "local" do %> + + <% else %> + + <% end %> + <% end %> +
    +
    + + +
    + +
    +
    + +
    + <%= @attendees_nb %> +
    +
    + +
    \ No newline at end of file diff --git a/lib/claper_web/live/event_live/show.ex b/lib/claper_web/live/event_live/show.ex new file mode 100644 index 0000000..500e11d --- /dev/null +++ b/lib/claper_web/live/event_live/show.ex @@ -0,0 +1,583 @@ +defmodule ClaperWeb.EventLive.Show do + use ClaperWeb, :live_view + + alias Claper.{Posts, Polls} + alias ClaperWeb.Presence + + on_mount(ClaperWeb.AttendeeLiveAuth) + + @impl true + def mount(%{"code" => code}, session, socket) do + with %{"locale" => locale} <- session do + Gettext.put_locale(ClaperWeb.Gettext, locale) + end + + event = + Claper.Events.get_event_with_code(code, + presentation_file: [:presentation_state], + user: [] + ) + + if is_nil(event) do + {:ok, + socket + |> put_flash(:error, gettext("Presentation doesn't exist")) + |> redirect(to: "/")} + else + init( + socket, + event, + check_if_banned(event.presentation_file.presentation_state.banned, socket) + ) + end + end + + defp check_if_banned(banned, %{assigns: %{current_user: current_user} = _assigns} = _socket) + when is_map(current_user) do + Enum.member?(banned, "#{current_user.id}") + end + + defp check_if_banned( + banned, + %{assigns: %{attendee_identifier: attendee_identifier} = _assigns} = _socket + ) do + Enum.member?(banned, attendee_identifier) + end + + defp init(socket, _event, true) do + {:ok, + socket + |> put_flash(:error, gettext("You have been banned from this event")) + |> redirect(to: "/")} + end + + defp init(socket, event, false) do + if connected?(socket) do + Claper.Events.Event.subscribe(event.uuid) + + Presence.track( + self(), + "event:#{event.uuid}", + socket.assigns.attendee_identifier, + %{} + ) + end + + post_changeset = Posts.Post.changeset(%Posts.Post{}, %{}) + + online = Presence.list("event:#{event.uuid}") |> Enum.count() + + maybe_update_audience_peak(event, online) + + socket = + socket + |> assign(:attendees_nb, 1) + |> assign(:post_changeset, post_changeset) + |> assign(:posts, list_posts(socket, event.uuid)) + |> assign(:liked_posts, reacted_posts(socket, event.id, "👍")) + |> assign(:loved_posts, reacted_posts(socket, event.id, "❤️")) + |> assign(:loled_posts, reacted_posts(socket, event.id, "😂")) + |> assign(:selected_poll_opt, nil) + |> assign(:poll_opt_saved, false) + |> assign(:event, event) + |> assign(:state, event.presentation_file.presentation_state) + |> starting_soon_assigns(event) + |> get_current_poll(event) + |> check_leader(event) + |> leader_list(event) + + {:ok, socket |> assign(:empty_room, Enum.count(socket.assigns.posts) == 0), + temporary_assigns: [posts: []]} + + end + + defp leader_list(socket, event) do + assign(socket, :leaders, Claper.Events.get_activity_leaders_for_event(event.id)) + end + + defp maybe_update_audience_peak(event, online) do + if online > event.audience_peak do + Claper.Events.update_event(event, %{audience_peak: online}) + end + end + + defp check_leader(%{assigns: %{current_user: current_user} = _assigns} = socket, event) + when is_map(current_user) do + is_leader = + current_user.id == event.user_id || Claper.Events.is_leaded_by(current_user.email, event) + + socket |> assign(:is_leader, is_leader) + end + + defp check_leader(socket, _event), do: socket |> assign(:is_leader, false) + + defp starting_soon_assigns(socket, event) do + if not Claper.Events.Event.started?(event) do + :timer.send_interval(1000, self(), :tick) + + diff = + DateTime.to_unix(DateTime.from_naive!(event.started_at, "Etc/UTC")) - + DateTime.to_unix(DateTime.utc_now()) + + with {days, hours, minutes, seconds} <- seconds_to_d_h_m_s(diff) do + socket + |> assign(:remaining_days, days) + |> assign(:remaining_hours, hours) + |> assign(:remaining_minutes, minutes) + |> assign(:remaining_seconds, seconds) + |> assign(:diff, diff) + |> assign(:started, false) + end + else + socket |> assign(:started, true) + end + end + + defp seconds_to_d_h_m_s(seconds) do + {div(seconds, 86400), rem(seconds, 86400) |> div(3600), rem(seconds, 3600) |> div(60), + rem(seconds, 3600) |> rem(60)} + end + + @impl true + def handle_info(:tick, %{assigns: %{diff: 0}} = socket) do + {:noreply, + socket + |> redirect( + to: Routes.event_show_path(socket, :show, String.downcase(socket.assigns.event.code)) + )} + end + + @impl true + def handle_info(:tick, %{assigns: %{diff: diff}} = socket) do + with {days, hours, minutes, seconds} <- seconds_to_d_h_m_s(diff) do + {:noreply, + socket + |> assign(:remaining_days, days) + |> assign(:remaining_hours, hours) + |> assign(:remaining_minutes, minutes) + |> assign(:remaining_seconds, seconds) + |> assign(:diff, diff - 1)} + end + end + + @impl true + def handle_info(%{event: "presence_diff"}, %{assigns: %{event: event}} = socket) do + attendees = Presence.list("event:#{event.uuid}") + + {:noreply, assign(socket, :attendees_nb, Enum.count(attendees))} + end + + @impl true + def handle_info({:post_created, post}, socket) do + {:noreply, + socket + |> update(:posts, fn posts -> [post | posts] end) + |> push_event("scroll", %{}) + |> maybe_disable_empty_room} + end + + @impl true + def handle_info( + {:banned, user_id}, + %{assigns: %{current_user: current_user} = _assigns} = socket + ) + when is_map(current_user) do + if user_id == current_user.id do + {:noreply, + socket + |> put_flash(:error, gettext("You have been banned from this event")) + |> push_redirect(to: Routes.event_join_path(socket, :index))} + else + {:noreply, socket} + end + end + + @impl true + def handle_info( + {:banned, attendee_identifier}, + %{assigns: %{attendee_identifier: current_attendee_identifier} = _assigns} = socket + ) do + if attendee_identifier == current_attendee_identifier do + {:noreply, + socket + |> put_flash(:error, gettext("You have been banned from this event")) + |> push_redirect(to: Routes.event_join_path(socket, :index))} + else + {:noreply, socket} + end + end + + @impl true + def handle_info({:page_changed, page}, socket) do + {:noreply, socket |> assign(:current_page, page) |> push_event("reset-global-react", %{})} + end + + @impl true + def handle_info( + {:current_poll, poll}, + socket + ) do + if is_nil(poll) do + {:noreply, socket |> assign(:current_poll, poll)} + else + {:noreply, socket |> assign(:current_poll, poll) |> get_current_vote(poll.id)} + end + end + + @impl true + def handle_info({:post_updated, post}, socket) do + {:noreply, socket |> update(:posts, fn posts -> [post | posts] end)} + end + + @impl true + def handle_info({:reaction_added, post}, socket) do + {:noreply, socket |> update(:posts, fn posts -> [post | posts] end)} + end + + @impl true + def handle_info({:reaction_removed, post}, socket) do + {:noreply, socket |> update(:posts, fn posts -> [post | posts] end)} + end + + @impl true + def handle_info({:post_deleted, post}, socket) do + {:noreply, socket |> update(:posts, fn posts -> [post | posts] end)} + end + + @impl true + def handle_info({:poll_updated, poll}, socket) do + {:noreply, + socket + |> update(:current_poll, fn _current_poll -> poll end)} + end + + @impl true + def handle_info({:poll_deleted, _poll}, socket) do + {:noreply, + socket + |> update(:current_poll, fn _current_poll -> nil end)} + end + + @impl true + def handle_info({:react, type}, socket) do + {:noreply, + socket + |> push_event("global-react", %{type: type})} + end + + @impl true + def handle_info(_, socket) do + {:noreply, socket} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + @impl true + def handle_event("delete", %{"event-id" => event_id, "id" => id}, socket) do + post = Posts.get_post!(id, [:event]) + {:ok, _} = Posts.delete_post(post) + + {:noreply, assign(socket, :posts, list_posts(socket, event_id))} + end + + @impl true + def handle_event( + "save", + %{"post" => post_params}, + %{assigns: %{current_user: current_user} = _assigns} = socket + ) + when is_map(current_user) do + post_params = + post_params + |> Map.put("user_id", current_user.id) + |> Map.put("position", socket.assigns.state.position) + + case Posts.create_post(socket.assigns.event, post_params) do + {:ok, _post} -> + {:noreply, socket} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, changeset: changeset)} + end + end + + @impl true + def handle_event( + "save", + %{"post" => post_params}, + %{assigns: %{attendee_identifier: attendee_identifier} = _assigns} = socket + ) do + post_params = + post_params + |> Map.put("attendee_identifier", attendee_identifier) + |> Map.put("position", socket.assigns.state.position) + + case Posts.create_post(socket.assigns.event, post_params) do + {:ok, _post} -> + {:noreply, socket} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, changeset: changeset)} + end + end + + @impl true + def handle_event( + "global-react", + %{"type" => type}, + socket + ) do + Phoenix.PubSub.broadcast( + Claper.PubSub, + "event:#{socket.assigns.event.uuid}", + {:react, String.to_atom(type)} + ) + + {:noreply, socket} + end + + @impl true + def handle_event( + "react", + %{"type" => type, "post-id" => post_id} = _params, + %{assigns: %{current_user: current_user} = _assigns} = socket + ) + when is_map(current_user) do + case type do + "👍" -> {:noreply, add_post_like(socket, post_id, %{icon: type, user_id: current_user.id})} + "❤️" -> {:noreply, add_post_love(socket, post_id, %{icon: type, user_id: current_user.id})} + "😂" -> {:noreply, add_post_lol(socket, post_id, %{icon: type, user_id: current_user.id})} + end + end + + @impl true + def handle_event( + "react", + %{"type" => type, "post-id" => post_id} = _params, + %{assigns: %{attendee_identifier: attendee_identifier} = _assigns} = socket + ) do + case type do + "👍" -> + {:noreply, + add_post_like(socket, post_id, %{icon: type, attendee_identifier: attendee_identifier})} + + "❤️" -> + {:noreply, + add_post_love(socket, post_id, %{icon: type, attendee_identifier: attendee_identifier})} + + "😂" -> + {:noreply, + add_post_lol(socket, post_id, %{icon: type, attendee_identifier: attendee_identifier})} + end + end + + @impl true + def handle_event( + "unreact", + %{"type" => type, "post-id" => post_id} = _params, + %{assigns: %{current_user: current_user} = _assigns} = socket + ) + when is_map(current_user) do + case type do + "👍" -> + {:noreply, remove_post_like(socket, post_id, %{icon: type, user_id: current_user.id})} + + "❤️" -> + {:noreply, remove_post_love(socket, post_id, %{icon: type, user_id: current_user.id})} + + "😂" -> + {:noreply, remove_post_lol(socket, post_id, %{icon: type, user_id: current_user.id})} + end + end + + @impl true + def handle_event( + "unreact", + %{"type" => type, "post-id" => post_id} = _params, + %{assigns: %{attendee_identifier: attendee_identifier} = _assigns} = socket + ) do + case type do + "👍" -> + {:noreply, + remove_post_like(socket, post_id, %{icon: type, attendee_identifier: attendee_identifier})} + + "❤️" -> + {:noreply, + remove_post_love(socket, post_id, %{icon: type, attendee_identifier: attendee_identifier})} + + "😂" -> + {:noreply, + remove_post_lol(socket, post_id, %{icon: type, attendee_identifier: attendee_identifier})} + end + end + + @impl true + def handle_event( + "select-poll-opt", + %{"opt" => opt}, + socket + ) do + {:noreply, socket |> assign(:selected_poll_opt, opt)} + end + + @impl true + def handle_event( + "vote", + %{"opt" => idx} = _params, + %{assigns: %{current_user: current_user}} = socket + ) + when is_map(current_user) do + {idx, _} = Integer.parse(idx) + poll_opt = Enum.at(socket.assigns.current_poll.poll_opts, idx) + + case Claper.Polls.vote( + current_user.id, + socket.assigns.event.uuid, + poll_opt, + socket.assigns.current_poll.id + ) do + {:ok, poll} -> + {:noreply, socket |> get_current_vote(poll.id)} + end + end + + @impl true + def handle_event( + "vote", + %{"opt" => idx} = _params, + %{assigns: %{attendee_identifier: attendee_identifier}} = socket + ) do + {idx, _} = Integer.parse(idx) + poll_opt = Enum.at(socket.assigns.current_poll.poll_opts, idx) + + case Claper.Polls.vote( + attendee_identifier, + socket.assigns.event.uuid, + poll_opt, + socket.assigns.current_poll.id + ) do + {:ok, poll} -> + {:noreply, socket |> get_current_vote(poll.id)} + end + end + + def toggle_side_menu(js \\ %JS{}) do + js + |> JS.toggle( + to: "#side-menu-shadow", + out: "animate__animated animate__fadeOut", + in: "animate__animated animate__fadeIn" + ) + |> JS.toggle( + to: "#side-menu", + out: "animate__animated animate__slideOutLeft", + in: "animate__animated animate__slideInLeft" + ) + end + + defp add_post_like(socket, post_id, params) do + with post <- Posts.get_post!(post_id, [:event]), + {:ok, _} <- Posts.create_reaction(Map.merge(params, %{post: post})) do + {:ok, _} = Posts.update_post(post, %{like_count: post.like_count + 1}) + update(socket, :liked_posts, fn liked_posts -> [post.id | liked_posts] end) + end + end + + defp remove_post_like(socket, post_id, params) do + with post <- Posts.get_post!(post_id, [:event]), + {:ok, _} <- Posts.delete_reaction(Map.merge(params, %{post: post})) do + {:ok, _} = Posts.update_post(post, %{like_count: post.like_count - 1}) + update(socket, :liked_posts, fn liked_posts -> List.delete(liked_posts, post.id) end) + end + end + + defp add_post_love(socket, post_id, params) do + with post <- Posts.get_post!(post_id, [:event]), + {:ok, _} <- Posts.create_reaction(Map.merge(params, %{post: post})) do + {:ok, _} = Posts.update_post(post, %{love_count: post.love_count + 1}) + update(socket, :loved_posts, fn loved_posts -> [post.id | loved_posts] end) + end + end + + defp remove_post_love(socket, post_id, params) do + with post <- Posts.get_post!(post_id, [:event]), + {:ok, _} <- Posts.delete_reaction(Map.merge(params, %{post: post})) do + {:ok, _} = Posts.update_post(post, %{love_count: post.love_count - 1}) + update(socket, :loved_posts, fn loved_posts -> List.delete(loved_posts, post.id) end) + end + end + + defp add_post_lol(socket, post_id, params) do + with post <- Posts.get_post!(post_id, [:event]), + {:ok, _} <- Posts.create_reaction(Map.merge(params, %{post: post})) do + {:ok, _} = Posts.update_post(post, %{lol_count: post.lol_count + 1}) + update(socket, :loled_posts, fn loled_posts -> [post.id | loled_posts] end) + end + end + + defp remove_post_lol(socket, post_id, params) do + with post <- Posts.get_post!(post_id, [:event]), + {:ok, _} <- Posts.delete_reaction(Map.merge(params, %{post: post})) do + {:ok, _} = Posts.update_post(post, %{lol_count: post.lol_count - 1}) + update(socket, :loled_posts, fn loled_posts -> List.delete(loled_posts, post.id) end) + end + end + + defp list_posts(_socket, event_id) do + Posts.list_posts(event_id, [:event, :reactions, :user]) + end + + defp get_current_poll(socket, event) do + with poll <- + Polls.get_poll_current_position( + event.presentation_file.id, + event.presentation_file.presentation_state.position + ) do + if is_nil(poll) do + socket |> assign(:current_poll, poll) + else + socket |> assign(:current_poll, poll) |> get_current_vote(poll.id) + end + end + end + + defp get_current_vote(%{assigns: %{current_user: current_user}} = socket, poll_id) + when is_map(current_user) do + vote = Polls.get_poll_vote(current_user.id, poll_id) + socket |> assign(:current_poll_vote, vote) + end + + defp get_current_vote(%{assigns: %{attendee_identifier: attendee_identifier}} = socket, poll_id) do + vote = Polls.get_poll_vote(attendee_identifier, poll_id) + socket |> assign(:current_poll_vote, vote) + end + + defp reacted_posts( + %{assigns: %{current_user: current_user} = _assigns} = _socket, + event_id, + icon + ) + when is_map(current_user) do + Posts.reacted_posts(event_id, current_user.id, icon) + end + + defp reacted_posts( + %{assigns: %{attendee_identifier: attendee_identifier} = _assigns} = _socket, + event_id, + icon + ) do + Posts.reacted_posts(event_id, attendee_identifier, icon) + end + + defp apply_action(socket, :show, _params) do + socket + |> assign(:page_title, "##{socket.assigns.event.code} - #{socket.assigns.event.name}") + end + + defp maybe_disable_empty_room(%{assigns: %{empty_room: empty_room}} = socket) do + if empty_room, do: assign(socket, :empty_room, false), else: socket + end +end diff --git a/lib/claper_web/live/event_live/show.html.heex b/lib/claper_web/live/event_live/show.html.heex new file mode 100644 index 0000000..3f96ac2 --- /dev/null +++ b/lib/claper_web/live/event_live/show.html.heex @@ -0,0 +1,135 @@ +<%= if @started do %> +
    + +
    + + + +
    + +
    +
    + + +
    + <%= @attendees_nb %> +
    +
    +
    + + <%= if @current_poll do %> +
    +
    + <.live_component module={ClaperWeb.EventLive.PollComponent} id={"#{@current_poll.id}-poll"} poll={@current_poll} current_user={@current_user} attendee_identifier={@attendee_identifier} event={@event} selected_poll_opt={@selected_poll_opt} current_poll_vote={@current_poll_vote} /> +
    +
    + <% end %> + +
    + <%= for post <- @posts do %> + <.live_component module={ClaperWeb.EventLive.PostComponent} id={"#{post.id}-post"} post={post} leaders={@leaders} is_leader={@is_leader} current_user={@current_user} attendee_identifier={@attendee_identifier} event={@event} liked_posts={@liked_posts} loved_posts={@loved_posts} loled_posts={@loled_posts} /> + <% end %> +
    + + <%= if @empty_room do %> +
    + <%= gettext "Be the first to react !" %> + +
    + <% end %> + +
    +
    + + <.form + let={f} + for={@post_changeset} + id="post-form" + class="fixed bottom-12 w-full lg:w-1/3 lg:mx-auto" + phx-hook="PostForm" + phx-submit="save"> + +
    + +
    + <%= textarea f, :body, id: "postFormTA", class: "bg-transparent outline-none w-full text-white h-10 placeholder-white pt-3 resize-none pr-20 leading-4 overflow-y-hidden focus:overflow-y-auto", placeholder: gettext("Ask, comment...") %> +
    +
    + <%= error_tag f, :body %> + <%= submit "Save", phx_disable_with: "Saving...", id: "hiddenSubmit", class: "hidden" %> + + +
    + + + + + + + + + + + + + + + + + +
    + + +
    +<% else %> +
    +
    +
    +
    +

    <%= @event.name %>

    + +

    + +
    +
    <%= if @remaining_days < 10, do: "0" %><%= @remaining_days %><%= gettext "days" %>
    +
    <%= if @remaining_hours < 10, do: "0" %><%= @remaining_hours %><%= gettext "hours" %>
    +
    <%= if @remaining_minutes < 10, do: "0" %><%= @remaining_minutes %><%= gettext "minutes" %>
    +
    <%= if @remaining_seconds < 10, do: "0" %><%= @remaining_seconds %><%= gettext "seconds" %>
    +
    +
    +
    + +
    +
    +<% end %> \ No newline at end of file diff --git a/lib/claper_web/live/live_helpers.ex b/lib/claper_web/live/live_helpers.ex new file mode 100644 index 0000000..318dc1d --- /dev/null +++ b/lib/claper_web/live/live_helpers.ex @@ -0,0 +1,24 @@ +defmodule ClaperWeb.LiveHelpers do + import Phoenix.LiveView.Helpers + + @doc """ + Renders a component inside the `ClaperWeb.ModalComponent` component. + + The rendered modal receives a `:return_to` option to properly update + the URL when the modal is closed. + + ## Examples + + <%= live_modal ClaperWeb.PostLive.FormComponent, + id: @post.id || :new, + action: @live_action, + post: @post, + return_to: Routes.post_index_path(@socket, :index) %> + """ + def live_modal(component, opts) do + path = Keyword.fetch!(opts, :return_to) + modal_opts = [id: :modal, return_to: path, component: component, opts: opts] + live_component(ClaperWeb.ModalComponent, modal_opts) + end + +end diff --git a/lib/claper_web/live/modal_component.ex b/lib/claper_web/live/modal_component.ex new file mode 100644 index 0000000..66328ac --- /dev/null +++ b/lib/claper_web/live/modal_component.ex @@ -0,0 +1,51 @@ +defmodule ClaperWeb.ModalComponent do + use ClaperWeb, :live_component + + @impl true + def render(assigns) do + ~H""" + + """ + end + + @impl true + def handle_event("hide", _, socket) do + {:noreply, + socket + |> push_patch(to: socket.assigns.return_to)} + end + + def hide_modal(js \\ %JS{}) do + js + |> JS.hide(to: "#modal", transition: "animate__animated animate__fadeOut", time: 300) + |> JS.push("hide", target: "#modal") + end +end diff --git a/lib/claper_web/live/poll_live/form_component.ex b/lib/claper_web/live/poll_live/form_component.ex new file mode 100644 index 0000000..7c76a1e --- /dev/null +++ b/lib/claper_web/live/poll_live/form_component.ex @@ -0,0 +1,119 @@ +defmodule ClaperWeb.PollLive.FormComponent do + use ClaperWeb, :live_component + + alias Claper.Polls + + @impl true + def update(%{poll: poll} = assigns, socket) do + changeset = Polls.change_poll(poll) + + {:ok, + socket + |> assign(assigns) + |> assign_new(:dark, fn -> false end) + |> assign(:polls, list_polls(assigns)) + |> assign(:changeset, changeset)} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + poll = Polls.get_poll!(id) + {:ok, _} = Polls.delete_poll(socket.assigns.event_uuid, poll) + + {:noreply, socket |> push_redirect(to: socket.assigns.return_to)} + end + + @impl true + def handle_event("validate", %{"poll" => poll_params}, socket) do + changeset = + socket.assigns.poll + |> Polls.change_poll(poll_params) + |> Map.put(:action, :validate) + + {:noreply, socket |> assign(:changeset, changeset)} + end + + @impl true + def handle_event("save", %{"poll" => poll_params}, socket) do + save_poll(socket, socket.assigns.live_action, poll_params) + end + + @impl true + def handle_event("add_opt", _params, %{assigns: %{changeset: changeset}} = socket) do + {:noreply, assign(socket, :changeset, changeset |> Polls.add_poll_opt())} + end + + @impl true + def handle_event( + "remove_opt", + %{"opt" => opt} = _params, + %{assigns: %{changeset: changeset}} = socket + ) do + {opt, _} = Integer.parse(opt) + + poll_opt = Enum.at(Ecto.Changeset.get_field(changeset, :poll_opts), opt) + + {:noreply, assign(socket, :changeset, changeset |> Polls.remove_poll_opt(poll_opt))} + end + + defp save_poll(socket, :edit, poll_params) do + case Polls.update_poll( + socket.assigns.event_uuid, + socket.assigns.poll, + poll_params + ) do + {:ok, _poll} -> + {:noreply, + socket + |> push_redirect(to: socket.assigns.return_to)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, :changeset, changeset)} + end + end + + defp save_poll(socket, :new, poll_params) do + case Polls.create_poll( + poll_params + |> Map.put("presentation_file_id", socket.assigns.presentation_file.id) + |> Map.put("position", socket.assigns.position) + |> maybe_enable(socket) + ) do + {:ok, poll} -> + {:noreply, + socket + |> maybe_change_current_poll(poll) + |> push_redirect(to: socket.assigns.return_to)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, changeset: changeset)} + end + end + + defp maybe_change_current_poll(socket, %{enabled: true} = poll) do + poll = Polls.get_poll!(poll.id) + + Phoenix.PubSub.broadcast( + Claper.PubSub, + "event:#{socket.assigns.event_uuid}", + {:current_poll, poll} + ) + + socket + end + + defp maybe_change_current_poll(socket, _), do: socket + + defp maybe_enable(poll_params, socket) do + has_current_poll = + socket.assigns.polls + |> Enum.filter(fn p -> p.position == socket.assigns.position && p.enabled == true end) + |> Enum.count() > 0 + + poll_params |> Map.put("enabled", !has_current_poll) + end + + defp list_polls(assigns) do + Polls.list_polls(assigns.presentation_file.id) + end +end diff --git a/lib/claper_web/live/poll_live/form_component.html.heex b/lib/claper_web/live/poll_live/form_component.html.heex new file mode 100644 index 0000000..ddde864 --- /dev/null +++ b/lib/claper_web/live/poll_live/form_component.html.heex @@ -0,0 +1,53 @@ +
    + <.form + let={f} + for={@changeset} + id="poll-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save"> + +
    + +
    + + <%= inputs_for f, :poll_opts, fn i -> %> +
    +
    + +
    + <%= if i.index >= 2 do %> + + <% end %> +
    + <% end %> + + + +
    + + <%= if @live_action == :edit do %> + <%= link gettext("Delete"), to: "#", phx_click: "delete", phx_target: @myself,phx_value_id: @poll.id, data: [confirm: gettext("This will delete all responses associated and the poll itself, are you sure?")], class: "w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500" %> + <% end %> +
    + + +
    \ No newline at end of file diff --git a/lib/claper_web/live/stat_live/index.ex b/lib/claper_web/live/stat_live/index.ex new file mode 100644 index 0000000..33dd434 --- /dev/null +++ b/lib/claper_web/live/stat_live/index.ex @@ -0,0 +1,72 @@ +defmodule ClaperWeb.StatLive.Index do + use ClaperWeb, :live_view + + alias Claper.Events + + on_mount(ClaperWeb.UserLiveAuth) + + @impl true + def mount(%{"id" => id}, session, socket) do + with %{"locale" => locale} <- session do + Gettext.put_locale(ClaperWeb.Gettext, locale) + end + + event = + Events.get_managed_event!(socket.assigns.current_user, id, + presentation_file: [polls: [:poll_opts]] + ) + + grouped_total_votes = Claper.Stats.total_vote_count(event.presentation_file.id) + distinct_poster_count = Claper.Stats.distinct_poster_count(event.id) + + {:ok, + socket + |> assign(:event, event) + |> assign( + :distinct_poster_count, + distinct_poster_count + ) + |> assign( + :grouped_total_votes, + grouped_total_votes + ) + |> assign(:average_voters, average_voters(grouped_total_votes)) + |> assign(:engagement_rate, calculate_engagement_rate(grouped_total_votes, distinct_poster_count, event)) + |> assign(:posts, list_posts(socket, event.uuid))} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "Report") + end + + defp calculate_engagement_rate(grouped_total_votes, distinct_poster_count, event) do + total_polls = Enum.count(grouped_total_votes) + + if total_polls == 0 do + (distinct_poster_count/event.audience_peak) * 100 |> Float.round |> :erlang.float_to_binary(decimals: 0) |> :erlang.binary_to_integer + else + (((distinct_poster_count/event.audience_peak) + (average_voters(grouped_total_votes))/event.audience_peak) / 2) * 100 |> Float.round |> :erlang.float_to_binary(decimals: 0) |> :erlang.binary_to_integer + end + end + + defp average_voters(grouped_total_votes) do + total_polls = Enum.count(grouped_total_votes) + + if total_polls == 0 do + 0 + else + (Enum.sum(grouped_total_votes)/total_polls) |> Float.round |> :erlang.float_to_binary(decimals: 0) |> :erlang.binary_to_integer + end + + end + + defp list_posts(_socket, event_id) do + Claper.Posts.list_posts(event_id, [:event, :reactions]) + end +end diff --git a/lib/claper_web/live/stat_live/index.html.heex b/lib/claper_web/live/stat_live/index.html.heex new file mode 100644 index 0000000..6dfff79 --- /dev/null +++ b/lib/claper_web/live/stat_live/index.html.heex @@ -0,0 +1,178 @@ +
    +
    +
    +

    + <%= @page_title %> +

    +
    + +
    + +
    +

    <%= gettext "Event" %>: <%= @event.name %> (#<%= @event.code %>)

    + +
    +
    +
    +
    + + + +
    +

    <%= gettext "Audience peak" %>

    +
    +
    +

    + <%= @event.audience_peak %> <%= ngettext "attendee", "attendees", @event.audience_peak %> +

    +
    +
    + +
    +
    +
    + + + + +
    +

    <%= gettext "Messages" %>

    +
    +
    +

    + <%= length @posts %> <%= ngettext "from %{count} people", "from %{count} peoples", @distinct_poster_count %> +

    +
    +
    + +
    +
    +
    + + + + +
    +

    <%= gettext "Average voters" %>

    +
    +
    +

    + <%= @average_voters %> <%= ngettext "from %{count} poll", "from %{count} polls", length(@event.presentation_file.polls) %> +

    +
    +
    + +
    +
    +
    + + +
    +

    <%= gettext "Engagement rate" %>

    +
    +
    +

    <%= @engagement_rate %>%

    +
    +
    +
    + +
    + +
    +

    <%= gettext "Interactions history" %>

    + <%= for position <- 0..@event.presentation_file.length-1 do %> +
    + <%= if System.get_env("PRESENTATION_STORAGE") == "local" do %> + + <% else %> + + <% end %> + + <%= for poll <- Enum.filter(@event.presentation_file.polls, fn p -> p.position == position end) do %> + <% total = Enum.map(poll.poll_opts, fn e -> e.vote_count end) |> Enum.sum() %> +
    + +
    +

    <%= poll.title %>

    +
    +
    +
    + <%= if (length poll.poll_opts) > 0 do %> + <%= for {opt, idx} <- Enum.with_index(poll.poll_opts) do %> + <% percentage = if total > 0, do: Float.round(opt.vote_count / total * 100) |> :erlang.float_to_binary(decimals: 0), else: 0 %> + + <% end %> + <% end %> +
    +
    +
    + <% end %> + + + <% posts = Enum.filter(@posts, fn p -> p.position == position end) %> + + <%= if length(posts) == 0 do %> + +

    <%= gettext "No messages has been sent" %>

    + + <% end %> + +
    + <%= for post <- posts do %> + +
    +
    + +
    + <%= if post.attendee_identifier do %> + + <% else %> + + <% end %> + +

    <%= post.body %>

    +
    + + + <%= if post.like_count > 0 || post.love_count > 0 || post.lol_count > 0 do %> +
    +
    + <%= if post.like_count > 0 do %> + + <%= post.like_count %> + <% end %> +
    +
    + <%= if post.love_count > 0 do %> + + <%= post.love_count %> + <% end %> +
    +
    + <%= if post.lol_count > 0 do %> + + <%= post.lol_count %> + <% end %> +
    +
    + <% end %> +
    +
    + <% end %> +
    +
    + <% end %> +
    + + +
    diff --git a/lib/claper_web/live/user_live_auth.ex b/lib/claper_web/live/user_live_auth.ex new file mode 100644 index 0000000..8bd68b3 --- /dev/null +++ b/lib/claper_web/live/user_live_auth.ex @@ -0,0 +1,22 @@ +defmodule ClaperWeb.UserLiveAuth do + import Phoenix.LiveView + alias ClaperWeb.Router.Helpers, as: Routes + + def on_mount(:default, _params, %{"current_user" => current_user} = _session, socket) do + if current_user.confirmed_at do + socket = + socket + |> assign_new(:current_user, fn -> current_user end) + + {:cont, socket} + else + {:halt, + redirect(socket, + to: Routes.user_registration_path(socket, :confirm, %{email: current_user.email}) + )} + end + end + + def on_mount(:default, _params, _session, socket), + do: {:halt, redirect(socket, to: Routes.user_registration_path(socket, :confirm))} +end diff --git a/lib/claper_web/live/user_settings_live/form_component.ex b/lib/claper_web/live/user_settings_live/form_component.ex new file mode 100644 index 0000000..b5fb2d2 --- /dev/null +++ b/lib/claper_web/live/user_settings_live/form_component.ex @@ -0,0 +1,47 @@ +defmodule ClaperWeb.UserSettingsLive.FormComponent do + use ClaperWeb, :live_component + + alias Claper.Accounts + + @impl true + def update(assigns, socket) do + email_changeset = Accounts.User.email_changeset(%Accounts.User{}, %{}) + + {:ok, + socket + |> assign(:email_changeset, email_changeset) + |> assign(assigns)} + end + + @impl true + def handle_event("save", %{"action" => "update_email"} = params, socket) do + %{"user" => user_params} = params + + user = socket.assigns.current_user + + case Accounts.apply_user_email(user, user_params) do + {:ok, applied_user} -> + Accounts.deliver_update_email_instructions( + applied_user, + user.email, + &Routes.user_settings_url(socket, :confirm_email, &1) + ) + + {:noreply, + socket + |> put_flash( + :info, + gettext("A link to confirm your email change has been sent to the new address.") + ) + |> push_redirect(to: socket.assigns.return_to)} + + {:error, changeset} -> + {:noreply, assign(socket, :email_changeset, changeset)} + end + end + + @impl true + def handle_event("validate", _params, socket) do + {:noreply, socket} + end +end diff --git a/lib/claper_web/live/user_settings_live/form_component.html.heex b/lib/claper_web/live/user_settings_live/form_component.html.heex new file mode 100644 index 0000000..e5b43cb --- /dev/null +++ b/lib/claper_web/live/user_settings_live/form_component.html.heex @@ -0,0 +1,13 @@ +
    + <%= if @action == :edit_email do %> + <.form let={f} for={@email_changeset} phx-target={@myself} phx-submit="save" id="update_email" class="mt-5 md:flex md:items-end"> + + <%= hidden_input f, :action, name: "action", value: "update_email" %> + + + + <%= submit gettext("Save"), phx_disable_with: "Saving...", class: "mt-2 w-full h-14 inline-flex transition-all items-center justify-center px-4 py-2 shadow-sm font-medium rounded-md text-white bg-black hover:bg-primary-500 md:mt-0 md:ml-3 md:w-auto md:text-sm" %> + + <% end %> + +
    \ No newline at end of file diff --git a/lib/claper_web/live/user_settings_live/show.ex b/lib/claper_web/live/user_settings_live/show.ex new file mode 100644 index 0000000..b98b8d2 --- /dev/null +++ b/lib/claper_web/live/user_settings_live/show.ex @@ -0,0 +1,69 @@ +defmodule ClaperWeb.UserSettingsLive.Show do + use ClaperWeb, :live_view + + alias Claper.Accounts + + on_mount(ClaperWeb.UserLiveAuth) + + @impl true + def mount(_params, session, socket) do + with %{"locale" => locale} <- session do + Gettext.put_locale(ClaperWeb.Gettext, locale) + end + + email_changeset = Accounts.User.email_changeset(%Accounts.User{}, %{}) + + {:ok, socket |> assign(:email_changeset, email_changeset)} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :edit_email, _params) do + socket + |> assign(:page_title, gettext("Update your email")) + |> assign( + :page_description, + gettext("Change the email address you want associated with your account.") + ) + end + + defp apply_action(socket, :show, _params) do + socket + |> assign(:page_title, gettext("Settings")) + end + + @impl true + def handle_event("save", %{"action" => "update_email"} = params, socket) do + %{"user" => user_params} = params + + user = socket.assigns.current_user + + case Accounts.apply_user_email(user, user_params) do + {:ok, applied_user} -> + Accounts.deliver_update_email_instructions( + applied_user, + user.email, + &Routes.user_settings_url(socket, :confirm_email, &1) + ) + + {:noreply, + socket + |> put_flash( + :info, + gettext("A link to confirm your email change has been sent to the new address.") + ) + |> push_redirect(to: Routes.user_settings_show_path(socket, :show))} + + {:error, changeset} -> + {:noreply, assign(socket, :email_changeset, changeset)} + end + end + + @impl true + def handle_event("validate", _params, socket) do + {:noreply, socket} + end +end diff --git a/lib/claper_web/live/user_settings_live/show.html.heex b/lib/claper_web/live/user_settings_live/show.html.heex new file mode 100644 index 0000000..6660a11 --- /dev/null +++ b/lib/claper_web/live/user_settings_live/show.html.heex @@ -0,0 +1,61 @@ +
    +
    +
    +

    + <%= gettext("Settings") %> +

    +
    +
    +
    +
    + +
    + <%= if @live_action in [:edit_email] do %> + <.live_component module={ClaperWeb.ModalComponent} + class="hidden" + id="modal-wrapper" + title={@page_title} + description={@page_description} + return_to={Routes.user_settings_show_path(@socket, :show)}> + +
    + <.form let={f} for={@email_changeset} phx-submit="save" id="update_email" class="mt-5 md:flex md:items-end"> + + <%= hidden_input f, :action, name: "action", value: "update_email" %> + + + + <%= submit gettext("Save"), phx_disable_with: "Saving...", class: "mt-2 w-full h-14 inline-flex transition-all items-center justify-center px-4 py-2 shadow-sm font-medium rounded-md text-white bg-black hover:bg-primary-500 md:mt-0 md:ml-3 md:w-auto md:text-sm" %> + +
    + + + <% end %> + +
    +
    +

    + <%= gettext "Personal informations" %> +

    +

    + <%= gettext "Your personal informations only visible by you" %> +

    +
    +
    +
    +
    +
    + <%= gettext "Email address" %> +
    +
    + <%= @current_user.email %> + + <%= live_patch gettext("Change"), to: Routes.user_settings_show_path(@socket, :edit_email), class: "rounded-md font-medium text-purple-600 hover:text-purple-500" %> + +
    +
    +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/lib/claper_web/notifiers/user_notifier.ex b/lib/claper_web/notifiers/user_notifier.ex new file mode 100644 index 0000000..79d29ff --- /dev/null +++ b/lib/claper_web/notifiers/user_notifier.ex @@ -0,0 +1,28 @@ +defmodule ClaperWeb.Notifiers.UserNotifier do + use Phoenix.Swoosh, view: ClaperWeb.UserNotifierView, layout: {ClaperWeb.LayoutView, :email} + import ClaperWeb.Gettext + + def magic(email, url) do + new() + |> to(email) + |> from({"Alex from Claper", "alex@claper.co"}) + |> subject(gettext("Connect to Claper")) + |> render_body("magic.html", %{url: url}) + end + + def welcome(email) do + new() + |> to(email) + |> from({"Alex from Claper", "alex@claper.co"}) + |> subject(gettext("Next steps to boost your presentations")) + |> render_body("welcome.html", %{email: email}) + end + + def update_email(user, url) do + new() + |> to(user.email) + |> from({"Alex from Claper", "alex@claper.co"}) + |> subject(gettext("Update email instructions")) + |> render_body("change.html", %{user: user, url: url}) + end +end diff --git a/lib/claper_web/plugs/locale.ex b/lib/claper_web/plugs/locale.ex new file mode 100644 index 0000000..b9f40cb --- /dev/null +++ b/lib/claper_web/plugs/locale.ex @@ -0,0 +1,62 @@ +defmodule ClaperWeb.Plugs.Locale do + import Plug.Conn + + def init(_opts), do: nil + + def call(conn, _opts) do + + known_locales = Gettext.known_locales(ClaperWeb.Gettext) + accepted_languages = extract_accept_language(conn) |> Enum.reject(&String.length(&1) > 2 && not Enum.member?(known_locales, &1)) + + case accepted_languages do + [locale | _] -> + Gettext.put_locale(ClaperWeb.Gettext, locale) + + conn + |> put_session(:locale, locale) + + _ -> + conn + end + end + + def extract_accept_language(conn) do + case Plug.Conn.get_req_header(conn, "accept-language") do + [value | _] -> + value + |> String.split(",") + |> Enum.map(&parse_language_option/1) + |> Enum.sort(&(&1.quality > &2.quality)) + |> Enum.map(& &1.tag) + |> Enum.reject(&is_nil/1) + |> ensure_language_fallbacks() + + _ -> + [] + end + end + + defp parse_language_option(string) do + captures = Regex.named_captures(~r/^\s?(?[\w\-]+)(?:;q=(?[\d\.]+))?$/i, string) + + quality = + case Float.parse(captures["quality"] || "1.0") do + {val, _} -> val + _ -> 1.0 + end + + %{tag: captures["tag"], quality: quality} + end + + defp ensure_language_fallbacks(tags) do + Enum.flat_map(tags, fn tag -> + case String.split(tag, "-") do + [language, _country_variant] -> + if Enum.member?(tags, language), do: [tag], else: [tag, language] + + [_language] -> + [tag] + end + end) + end +end diff --git a/lib/claper_web/router.ex b/lib/claper_web/router.ex new file mode 100644 index 0000000..a9b317a --- /dev/null +++ b/lib/claper_web/router.ex @@ -0,0 +1,123 @@ +defmodule ClaperWeb.Router do + use ClaperWeb, :router + + import ClaperWeb.{UserAuth, EventController} + + pipeline :browser do + plug(:accepts, ["html"]) + plug(:fetch_session) + plug(:fetch_live_flash) + plug(:put_root_layout, {ClaperWeb.LayoutView, :root}) + plug(:protect_from_forgery) + plug(:put_secure_browser_headers) + plug(:fetch_current_user) + plug(ClaperWeb.Plugs.Locale) + end + + pipeline :api do + plug(:accepts, ["json"]) + end + + # Manage attendee_identifier in requests + pipeline :attendee_registration do + plug(:attendee_identifier) + end + + live_session :attendee do + scope "/", ClaperWeb do + pipe_through([:browser, :attendee_registration]) + + live("/", EventLive.Join, :index) + live("/join", EventLive.Join, :join) + live("/e/:code", EventLive.Show, :show) + end + end + + live_session :user, + root_layout: {ClaperWeb.LayoutView, "user.html"} do + scope "/", ClaperWeb do + pipe_through([:browser, :require_authenticated_user]) + + live("/events", EventLive.Index, :index) + live("/events/new", EventLive.Index, :new) + live("/events/:id/edit", EventLive.Index, :edit) + live("/events/:id/stats", StatLive.Index, :index) + + live("/users/settings", UserSettingsLive.Show, :show) + live("/users/settings/edit/password", UserSettingsLive.Show, :edit_password) + live("/users/settings/edit/email", UserSettingsLive.Show, :edit_email) + live("/users/settings/edit/avatar", UserSettingsLive.Show, :edit_avatar) + live("/users/settings/edit/fullname", UserSettingsLive.Show, :edit_full_name) + end + end + + live_session :presenter, on_mount: ClaperWeb.UserLiveAuth do + scope "/", ClaperWeb do + pipe_through([:browser, :require_authenticated_user]) + + live("/e/:code/presenter", EventLive.Presenter, :show) + live("/e/:code/manage", EventLive.Manage, :show) + live("/e/:code/manage/add/poll", EventLive.Manage, :add_poll) + live("/e/:code/manage/edit/poll/:id", EventLive.Manage, :edit_poll) + end + end + + # Enables LiveDashboard only for development + # + # 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). + if Mix.env() in [:dev, :test] do + import Phoenix.LiveDashboard.Router + + scope "/" do + pipe_through(:browser) + live_dashboard("/dashboard", metrics: ClaperWeb.Telemetry) + end + end + + # Enables the Swoosh mailbox preview in development. + # + # Note that preview only shows emails that were sent by the same + # node running the Phoenix server. + if Mix.env() == :dev do + scope "/dev" do + pipe_through(:browser) + + forward("/mailbox", Plug.Swoosh.MailboxPreview) + end + end + + ## Authentication routes + + scope "/", ClaperWeb do + pipe_through([:browser, :redirect_if_user_is_authenticated]) + + get("/users/register/confirm", UserRegistrationController, :confirm) + get("/users/log_in", UserSessionController, :new) + post("/users/log_in", UserSessionController, :create) + get("/users/magic/:token", UserConfirmationController, :confirm_magic) + end + + scope "/", ClaperWeb do + pipe_through([:browser, :require_authenticated_user]) + + post("/events/:uuid/slide.jpg", EventController, :slide_generate) + get("/users/settings/confirm_email/:token", UserSettingsController, :confirm_email) + end + + scope "/", ClaperWeb do + pipe_through([:browser]) + + get("/tos", PageController, :tos) + get("/privacy", PageController, :privacy) + + delete("/users/log_out", UserSessionController, :delete) + get("/users/confirm", UserConfirmationController, :new) + post("/users/confirm", UserConfirmationController, :create) + get("/users/confirm/:token", UserConfirmationController, :edit) + post("/users/confirm/:token", UserConfirmationController, :update) + end +end diff --git a/lib/claper_web/telemetry.ex b/lib/claper_web/telemetry.ex new file mode 100644 index 0000000..ea6b82b --- /dev/null +++ b/lib/claper_web/telemetry.ex @@ -0,0 +1,71 @@ +defmodule ClaperWeb.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.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + + # Database Metrics + summary("claper.repo.query.total_time", + unit: {:native, :millisecond}, + description: "The sum of the other measurements" + ), + summary("claper.repo.query.decode_time", + unit: {:native, :millisecond}, + description: "The time spent decoding the data received from the database" + ), + summary("claper.repo.query.query_time", + unit: {:native, :millisecond}, + description: "The time spent executing the query" + ), + summary("claper.repo.query.queue_time", + unit: {:native, :millisecond}, + description: "The time spent waiting for a database connection" + ), + summary("claper.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. + # {ClaperWeb, :count_users, []} + ] + end +end diff --git a/lib/claper_web/templates/error/404.html.heex b/lib/claper_web/templates/error/404.html.heex new file mode 100644 index 0000000..0c033b1 --- /dev/null +++ b/lib/claper_web/templates/error/404.html.heex @@ -0,0 +1,49 @@ + + + + + + + <%= csrf_meta_tag() %> + <%= live_title_tag assigns[:page_title] || "Claper", suffix: " · Claper.co" %> + + + + + + +
    + +
    +
    +
    +
    +
    +
    +
    +

    + + + +

    +

    + <%= gettext("Oops, page doesn't exist.") %> +

    + +
    + <%= live_patch gettext("Return to home"), to: Routes.event_join_path(@conn, :index), class: "text-sm text-white underline" %> +
    + +
    +
    +
    +
    +
    + +
    +
    +
    + + + \ No newline at end of file diff --git a/lib/claper_web/templates/error/500.html.heex b/lib/claper_web/templates/error/500.html.heex new file mode 100644 index 0000000..ecf890b --- /dev/null +++ b/lib/claper_web/templates/error/500.html.heex @@ -0,0 +1,50 @@ + + + + + + + <%= csrf_meta_tag() %> + <%= live_title_tag assigns[:page_title] || "Claper", suffix: " · Claper.co" %> + + + + + + +
    + +
    +
    +
    +
    +
    +
    +
    +

    + + + +

    +

    + <%= gettext("The site is under maintenance, we'll be back very soon!") %> +

    + +
    +

    Check status

    +

    <%= live_patch gettext("Return to home"), to: Routes.event_join_path(@conn, :index), class: "text-sm text-white underline" %>

    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    + + + \ No newline at end of file diff --git a/lib/claper_web/templates/layout/_avatar.html.heex b/lib/claper_web/templates/layout/_avatar.html.heex new file mode 100644 index 0000000..40b5bcc --- /dev/null +++ b/lib/claper_web/templates/layout/_avatar.html.heex @@ -0,0 +1,13 @@ +
    +<%= if @user.avatar do %> + avatar +<% else %> +
    + <%= if @user.full_name do %> + <%= with [first | _] <- String.codepoints(@user.full_name), do: String.capitalize(first) %> + <% else %> + <%= with [first | _] <- String.codepoints(@user.email), do: String.capitalize(first) %> + <% end %> +
    +<% end %> +
    \ No newline at end of file diff --git a/lib/claper_web/templates/layout/_profile_dropdown.html.heex b/lib/claper_web/templates/layout/_profile_dropdown.html.heex new file mode 100644 index 0000000..ca47f2e --- /dev/null +++ b/lib/claper_web/templates/layout/_profile_dropdown.html.heex @@ -0,0 +1,27 @@ +
    +
    + + + + + +
    +
    + +
    +
    + +
    + + + +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/lib/claper_web/templates/layout/_user_menu.html.heex b/lib/claper_web/templates/layout/_user_menu.html.heex new file mode 100644 index 0000000..16d3c3f --- /dev/null +++ b/lib/claper_web/templates/layout/_user_menu.html.heex @@ -0,0 +1,6 @@ +
    + <%= live_patch gettext("Settings"), to: Routes.user_settings_show_path(@conn, :show), class: "text-gray-700 block px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900" %> +
    +
    + <%= link gettext("Logout"), to: Routes.user_session_path(@conn, :delete), method: :delete, class: "text-gray-700 block px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900" %> +
    \ No newline at end of file diff --git a/lib/claper_web/templates/layout/app.html.heex b/lib/claper_web/templates/layout/app.html.heex new file mode 100644 index 0000000..ad182e9 --- /dev/null +++ b/lib/claper_web/templates/layout/app.html.heex @@ -0,0 +1,11 @@ +
    + <%= if get_flash(@conn, :info) do %> + + <% end %> + + <%= if get_flash(@conn, :error) do %> + + <% end %> +
    + +<%= @inner_content %> \ No newline at end of file diff --git a/lib/claper_web/templates/layout/email.html.heex b/lib/claper_web/templates/layout/email.html.heex new file mode 100644 index 0000000..27a7222 --- /dev/null +++ b/lib/claper_web/templates/layout/email.html.heex @@ -0,0 +1,52 @@ + + + + + + Reset Password Email Template + + + + + + + + + + +
    + + + + + + + + + + + + <%= @inner_content %> + + + + + + + +
     
    + + logo + +
     
    +

    © claper.co

    +
     
    +
    + + + + \ No newline at end of file diff --git a/lib/claper_web/templates/layout/live.html.heex b/lib/claper_web/templates/layout/live.html.heex new file mode 100644 index 0000000..ef49d66 --- /dev/null +++ b/lib/claper_web/templates/layout/live.html.heex @@ -0,0 +1,11 @@ +
    + <%= if live_flash(@flash, :info) do %> + + <% end %> + + <%= if live_flash(@flash, :error) do %> + + <% end %> +
    + +<%= @inner_content %> \ No newline at end of file diff --git a/lib/claper_web/templates/layout/root.html.heex b/lib/claper_web/templates/layout/root.html.heex new file mode 100644 index 0000000..4fce7b1 --- /dev/null +++ b/lib/claper_web/templates/layout/root.html.heex @@ -0,0 +1,25 @@ + + + + + + + <%= csrf_meta_tag() %> + <%= live_title_tag assigns[:page_title] || "Claper", suffix: " · Claper.co" %> + + + + + + +
    + +
    +
    + <%= @inner_content %> +
    +
    +
    + + + \ No newline at end of file diff --git a/lib/claper_web/templates/layout/user.html.heex b/lib/claper_web/templates/layout/user.html.heex new file mode 100644 index 0000000..8cbf114 --- /dev/null +++ b/lib/claper_web/templates/layout/user.html.heex @@ -0,0 +1,26 @@ + + + + + + + <%= csrf_meta_tag() %> + <%= live_title_tag assigns[:page_title] || "Claper", suffix: " · Claper.co" %> + + + + + + +
    + +
    + <%= render("_profile_dropdown.html", user: @current_user, conn: @conn) %> +
    + <%= @inner_content %> +
    +
    +
    + + + \ No newline at end of file diff --git a/lib/claper_web/templates/page/privacy.html.heex b/lib/claper_web/templates/page/privacy.html.heex new file mode 100644 index 0000000..80789eb --- /dev/null +++ b/lib/claper_web/templates/page/privacy.html.heex @@ -0,0 +1,93 @@ + + +
    + +
    + +

    Privacy Policy for Claper.co

    + +

    At Claper.co, accessible from https://claper.co and https://get.claper.co, one of our main priorities is the privacy of our visitors. This Privacy Policy document contains types of information that is collected and recorded by Claper.co and how we use it.

    + +

    General Data Protection Regulation (GDPR)

    +

    We are a Data Controller of your information.

    + +

    Claper.co legal basis for collecting and using the personal information described in this Privacy Policy depends on the Personal Information we collect and the specific context in which we collect the information:

    +
      +
    • Claper.co needs to perform a contract with you
    • +
    • You have given Claper.co permission to do so
    • +
    • Processing your personal information is in Claper.co legitimate interests
    • +
    • Claper.co needs to comply with the law
    • +
    + +

    Claper.co will retain your personal information only for as long as is necessary for the purposes set out in this Privacy Policy. We will retain and use your information to the extent necessary to comply with our legal obligations, resolve disputes, and enforce our policies.

    + +

    If you are a resident of the European Economic Area (EEA), you have certain data protection rights. If you wish to be informed what Personal Information we hold about you and if you want it to be removed from our systems, please contact us.

    +

    In certain circumstances, you have the following data protection rights:

    +
      +
    • The right to access, update or to delete the information we have on you.
    • +
    • The right of rectification.
    • +
    • The right to object.
    • +
    • The right of restriction.
    • +
    • The right to data portability
    • +
    • The right to withdraw consent
    • +
    + +

    Log Files

    + +

    Claper.co follows a standard procedure of using log files. These files log visitors when they visit websites. All hosting companies do this and a part of hosting services' analytics. The information collected by log files include internet protocol (IP) addresses, browser type, Internet Service Provider (ISP), date and time stamp, referring/exit pages, and possibly the number of clicks. These are not linked to any information that is personally identifiable. The purpose of the information is for analyzing trends, administering the site, tracking users' movement on the website, and gathering demographic information.

    + +

    Cookies and Web Beacons

    + +

    Like any other website, Claper.co uses 'cookies'. These cookies are used to store information including visitors' preferences, and the pages on the website that the visitor accessed or visited. The information is used to optimize the users' experience by customizing our web page content based on visitors' browser type and/or other information.

    + +

    For more general information on cookies, please read "Cookies" article from the European Commission website.

    + + + +

    Privacy Policies

    + +

    You may consult this list to find the Privacy Policy for each of the advertising partners of Claper.co.

    + +

    Third-party ad servers or ad networks uses technologies like cookies, JavaScript, or Web Beacons that are used in their respective advertisements and links that appear on Claper.co, which are sent directly to users' browser. They automatically receive your IP address when this occurs. These technologies are used to measure the effectiveness of their advertising campaigns and/or to personalize the advertising content that you see on websites that you visit.

    + +

    Note that Claper.co has no access to or control over these cookies that are used by third-party advertisers.

    + +

    Third Party Privacy Policies

    + +

    Claper.co's Privacy Policy does not apply to other advertisers or websites. Thus, we are advising you to consult the respective Privacy Policies of these third-party ad servers for more detailed information. It may include their practices and instructions about how to opt-out of certain options.

    + +

    You can choose to disable cookies through your individual browser options. To know more detailed information about cookie management with specific web browsers, it can be found at the browsers' respective websites.

    + +

    Online Privacy Policy Only

    + +

    Our Privacy Policy applies only to our online activities and is valid for visitors to our website with regards to the information that they shared and/or collect in Claper.co. This policy is not applicable to any information collected offline or via channels other than this website.

    + +

    Consent

    + +

    By using our website, you hereby consent to our Privacy Policy and agree to its terms.

    + +
    +
    \ No newline at end of file diff --git a/lib/claper_web/templates/page/tos.html.heex b/lib/claper_web/templates/page/tos.html.heex new file mode 100644 index 0000000..64a76d5 --- /dev/null +++ b/lib/claper_web/templates/page/tos.html.heex @@ -0,0 +1,96 @@ + + +
    + +
    + +

    Terms and Conditions of Claper.co

    + +

    The following terms and conditions (collectively, these "Terms and Conditions") apply to your use of Claper.co, including any content, functionality and services offered on or via Claper.co (the "website").

    + +

    Please read the Terms and Conditions carefully before you start using Claper.co, because by using the website you accept and agree to be bound and abide by these Terms and Conditions.

    + +

    These Terms and Conditions are effective as of 11/04/2022. We expressly reserve the right to change these Terms and Conditions from time to time without notice to you. You acknowledge and agree that it is your responsibility to review this website and these Terms and Conditions from time to time and to familiarize yourself with any modifications. Your continued use of this website after such modifications will constitute acknowledgement of the modified Terms and Conditions and agreement to abide and be bound by the modified Terms and Conditions.

    + +

    Conduct on website

    + +

    Your use of the website is subject to all applicable laws and regulations, and you are solely responsible for the substance of your communications through the website.

    + +

    By posting information in or otherwise using any communications service, messages, polls or other interactive service that may be available to you on or through this website, you agree that you will not upload, share, post, or otherwise distribute or facilitate distribution of any content — including text, communications, software, images, sounds, data, or other information — that:

    + +
      +
    • Is unlawful, threatening, abusive, harassing, defamatory, libelous, deceptive, fraudulent, invasive of another's privacy, tortious, contains explicit or graphic descriptions or accounts of sexual acts (including but not limited to sexual language of a violent or threatening nature directed at another individual or group of individuals), or otherwise violates our rules or policies
    • +
    • Victimizes, harasses, degrades, or intimidates an individual or group of individuals on the basis of religion, gender, sexual orientation, race, ethnicity, age, or disability
    • +
    • Infringes on any patent, trademark, trade secret, copyright, right of publicity, or other proprietary right of any party
    • +
    • Constitutes unauthorized or unsolicited advertising, junk or bulk email (also known as "spamming"), chain letters, any other form of unauthorized solicitation, or any form of lottery or gambling
    • +
    • Contains software viruses or any other computer code, files, or programs that are designed or intended to disrupt, damage, or limit the functioning of any software, hardware, or telecommunications equipment or to damage or obtain unauthorized access to any data or other information of any third party
    • +
    • Impersonates any person or entity, including any of our employees or representatives
    • +
    + +

    We neither endorse nor assume any liability for the contents of any material uploaded or submitted by third party users of the website. We generally do not pre-screen, monitor, or edit the content posted by users of communications services, messages, polls, or other interactive services that may be available on or through this website.

    + +

    However, we and our agents have the right at their sole discretion to remove any content that, in our judgment, does not comply with these Terms of Use and any other rules of user conduct for our website, or is otherwise harmful, objectionable, or inaccurate. We are not responsible for any failure or delay in removing such content. You hereby consent to such removal and waive any claim against us arising out of such removal of content.

    + +

    You agree that we may at any time, and at our sole discretion, terminate your membership, account, or other affiliation with our site without prior notice to you for violating any of the above provisions.

    + +

    In addition, you acknowledge that we will cooperate fully with investigations of violations of systems or network security at other sites, including cooperating with law enforcement authorities in investigating suspected criminal violations.

    + +

    Intellectual Property

    + +

    By accepting these Terms and Conditions, you acknowledge and agree that all content presented to you on this website is protected by copyrights, trademarks, service marks, patents or other proprietary rights and laws, and is the sole property of Claper.co.

    + +

    You are only permitted to use the content as expressly authorized by us or the specific content provider. Except for a single copy made for personal use only, you may not copy, reproduce, modify, republish, upload, post, transmit, or distribute any documents or information from this website in any form or by any means without prior written permission from us or the specific content provider, and you are solely responsible for obtaining permission before reusing any copyrighted material that is available on this website.

    + +

    Third Party websites

    + +

    This website may link you to other sites on the Internet or otherwise include references to information, documents, software, materials and/or services provided by other parties. These websites may contain information or material that some people may find inappropriate or offensive.

    + +

    These other websites and parties are not under our control, and you acknowledge that we are not responsible for the accuracy, copyright compliance, legality, decency, or any other aspect of the content of such sites, nor are we responsible for errors or omissions in any references to other parties or their products and services. The inclusion of such a link or reference is provided merely as a convenience and does not imply endorsement of, or association with, the website or party by us, or any warranty of any kind, either express or implied.

    + +

    Disclaimer of Warranties, Limitations of Liability and Indemnification

    + +

    Your use of Claper.co is at your sole risk. The website is provided "as is" and "as available". We disclaim all warranties of any kind, express or implied, including, without limitation, the warranties of merchantability, fitness for a particular purpose and non-infringement.

    + +

    We are not liable for damages, direct or consequential, resulting from your use of the website, and you agree to defend, indemnify and hold us harmless from any claims, losses, liability costs and expenses (including but not limites to attorney's fees) arising from your violation of any third-party's rights. You acknowledge that you have only a limited, non-exclusive, nontransferable license to use the website. Because the website is not error or bug free, you agree that you will use it carefully and avoid using it ways which might result in any loss of your or any third party's property or information.

    + +

    Term and termination

    + +

    This Terms and Conditions will become effective in relation to you when you create a Claper.co account or when you start using Claper.co and will remain effective until terminated by you or by us.

    + +

    Claper.co reserves the right to terminate this Terms and Conditions or suspend your account at any time in case of unauthorized, or suspected unauthorized use of the website whether in contravention of this Terms and Conditions or otherwise. If Claper.co terminates this Terms and Conditions, or suspends your account for any of the reasons set out in this section, Claper.co shall have no liability or responsibility to you.

    + +

    Assignment

    + +

    Claper.co may assign this Terms and Conditions or any part of it without restrictions. You may not assign this Terms and Conditions or any part of it to any third party.

    + +

    Governing Law

    + +

    These Terms and Conditions and any dispute or claim arising out of, or related to them, shall be governed by and construed in accordance with the internal laws of the France without giving effect to any choice or conflict of law provision or rule.

    + +

    Any legal suit, action or proceeding arising out of, or related to, these Terms of Service or the website shall be instituted exclusively in the federal courts of France.

    + +
    +
    \ No newline at end of file diff --git a/lib/claper_web/templates/page/user_confirmation/edit.html.heex b/lib/claper_web/templates/page/user_confirmation/edit.html.heex new file mode 100644 index 0000000..e9bf443 --- /dev/null +++ b/lib/claper_web/templates/page/user_confirmation/edit.html.heex @@ -0,0 +1,12 @@ +

    Confirm account

    + +<.form let={_f} for={:user} action={Routes.user_confirmation_path(@conn, :update, @token)}> +
    + <%= submit "Confirm my account" %> +
    + + +

    + <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | + <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> +

    diff --git a/lib/claper_web/templates/page/user_confirmation/new.html.heex b/lib/claper_web/templates/page/user_confirmation/new.html.heex new file mode 100644 index 0000000..4d9bee3 --- /dev/null +++ b/lib/claper_web/templates/page/user_confirmation/new.html.heex @@ -0,0 +1,15 @@ +

    Resend confirmation instructions

    + +<.form let={f} for={:user} action={Routes.user_confirmation_path(@conn, :create)}> + <%= label f, :email %> + <%= email_input f, :email, required: true %> + +
    + <%= submit "Resend confirmation instructions" %> +
    + + +

    + <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | + <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> +

    diff --git a/lib/claper_web/templates/user_notifier/change.html.heex b/lib/claper_web/templates/user_notifier/change.html.heex new file mode 100644 index 0000000..e267aab --- /dev/null +++ b/lib/claper_web/templates/user_notifier/change.html.heex @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + +
     
    +

    <%= gettext("Confirm email") %>

    + +

    + <%= gettext("You can change your email by visiting the URL below") %> +

    + <%= gettext("CONFIRM EMAIL") %> +

    + <%= gettext("If you didn't create an account with us, please ignore this.") %> +

    +
     
    +

    <%= gettext "If you’re having trouble with the button above, copy and paste the URL below into your web browser" %>.

    +

    <%= @url %>

    +
     
    + + + +   + diff --git a/lib/claper_web/templates/user_notifier/magic.html.heex b/lib/claper_web/templates/user_notifier/magic.html.heex new file mode 100644 index 0000000..b9ff11c --- /dev/null +++ b/lib/claper_web/templates/user_notifier/magic.html.heex @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + +
     
    +

    <%= gettext("Connect to Claper") %>

    + +

    + <%= gettext("You can log into your account by clicking here.") %> +

    + <%= gettext("ACCESS TO MY ACCOUNT") %> +

    + <%= gettext("If you didn't create an account with us, please ignore this.") %> +

    +
     
    +

    <%= gettext "If you’re having trouble with the button above, copy and paste the URL below into your web browser" %>.

    +

    <%= @url %>

    +
     
    + + + +   + diff --git a/lib/claper_web/templates/user_notifier/welcome.html.heex b/lib/claper_web/templates/user_notifier/welcome.html.heex new file mode 100644 index 0000000..418dc7c --- /dev/null +++ b/lib/claper_web/templates/user_notifier/welcome.html.heex @@ -0,0 +1,43 @@ + + + + + + + + + + + + +
     
    +

    <%= gettext("Welcome !") %>

    + +

    + <%= gettext("Congrats! You've taken the first step to improving your presentations. Here are the next steps to create step up your presentations with Claper:") %> +

    +
      +
    1. <%= raw(gettext("Export your current presentation to PDF from your favorite slide presentation software (PowerPoint, etc)")) %>
    2. +
    3. <%= raw(gettext("Click on the create button on your dashboard")) %>
    4. +
    5. <%= raw(gettext("Choose a name for your event, a code for your attendees to join and dates when your attendees could start interacting")) %>
    6. +
    7. <%= raw(gettext("Wait few minutes for your file to be processed")) %>
    8. +
    9. <%= raw(gettext("Click on Present/Customize to add interaction on your slides")) %>
    10. +
    11. <%= raw(gettext("Click Start to open your presentation and move the window on the big screen")) %>
    12. +
    13. <%= gettext("Enjoy ! ✨") %>
    14. +
    +

    <%= gettext("To have more than 25 attendees and create more than one presentation by month, upgrade your plan.") %>

    + <%= gettext("Upgrade") %> +

    + <%= gettext("If you have any questions, feel free to email us. We also offer live chat during business hours.") %> +

    + +
     
    + + + +   + \ No newline at end of file diff --git a/lib/claper_web/templates/user_registration/confirm.html.heex b/lib/claper_web/templates/user_registration/confirm.html.heex new file mode 100644 index 0000000..523d7f1 --- /dev/null +++ b/lib/claper_web/templates/user_registration/confirm.html.heex @@ -0,0 +1,32 @@ +
    +
    +
    +
    +
    +

    + + + +

    +

    + <%= if @conn.query_params["retry"] do %> + <%= gettext("We already sent you an email to login, please retry in 5 minutes.") %> + <% else %> + <%= if @conn.query_params["email"] do %> + <%= gettext("We sent you an email at") <> " #{@conn.query_params["email"]}" <> gettext(", click on the provided link to connect (check your spam !)") %> + <% else %> + <%= gettext("We sent you an email, click on the provided link to connect (check your spam !)") %> + <% end %> + <% end %> +

    + +
    + <%= live_patch gettext("Return to home"), to: Routes.event_join_path(@conn, :index), class: "text-sm text-white underline" %> +
    + +
    +
    +
    +
    +
    diff --git a/lib/claper_web/templates/user_registration/new.html.heex b/lib/claper_web/templates/user_registration/new.html.heex new file mode 100644 index 0000000..8effbec --- /dev/null +++ b/lib/claper_web/templates/user_registration/new.html.heex @@ -0,0 +1,33 @@ +
    +
    +
    +
    +
    +

    + + + +

    +

    + <%= gettext("Join the Claper experience") %> +

    +
    + + <.form let={f} for={@changeset} action={Routes.user_registration_path(@conn, :create)} class="mt-8 space-y-6"> + <%= if @changeset.action do %> + + <% end %> + + + +
    + +
    + +
    +
    +
    +
    diff --git a/lib/claper_web/templates/user_session/new.html.heex b/lib/claper_web/templates/user_session/new.html.heex new file mode 100644 index 0000000..740a50c --- /dev/null +++ b/lib/claper_web/templates/user_session/new.html.heex @@ -0,0 +1,46 @@ +
    +
    +
    + +
    +
    +
    +

    + +

    +

    + <%= gettext("It's time to empower your presentations.") %> +

    +

    <%= gettext("Connect to your account") %>

    +
    +
    + + <.form let={f} for={@conn} action={Routes.user_session_path(@conn, :create)} as={:user} class="mt-12 mb-4"> + <%= if @error_message do %> + + <% end %> + + + +
    + +
    + +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/lib/claper_web/views/attendee_registration_view.ex b/lib/claper_web/views/attendee_registration_view.ex new file mode 100644 index 0000000..aacad1a --- /dev/null +++ b/lib/claper_web/views/attendee_registration_view.ex @@ -0,0 +1,11 @@ +defmodule ClaperWeb.AttendeeRegistrationView do + use ClaperWeb, :view + + def render("attendee.json", %{attendee: attendee, token: token}) do + %{ + name: attendee.name, + avatar: attendee.avatar, + token: token + } + end +end diff --git a/lib/claper_web/views/component_view.ex b/lib/claper_web/views/component_view.ex new file mode 100644 index 0000000..d191823 --- /dev/null +++ b/lib/claper_web/views/component_view.ex @@ -0,0 +1,3 @@ +defmodule ClaperWeb.ComponentView do + use ClaperWeb, :view +end diff --git a/lib/claper_web/views/components/alert_component.ex b/lib/claper_web/views/components/alert_component.ex new file mode 100644 index 0000000..65b14b9 --- /dev/null +++ b/lib/claper_web/views/components/alert_component.ex @@ -0,0 +1,49 @@ +defmodule ClaperWeb.Component.Alert do + use ClaperWeb, :view_component + + def info(assigns) do + assigns = + assigns + |> assign_new(:stick, fn -> false end) + ~H""" +
    +
    +
    + +
    +
    +

    + <%= @message %> +

    +
    +
    +
    + """ + end + + def error(assigns) do + assigns = + assigns + |> assign_new(:stick, fn -> false end) + ~H""" +
    +
    +
    + + +
    +
    +

    + <%= @message %> +

    +
    +
    +
    + """ + end + +end diff --git a/lib/claper_web/views/components/input_component.ex b/lib/claper_web/views/components/input_component.ex new file mode 100644 index 0000000..d6a1a0b --- /dev/null +++ b/lib/claper_web/views/components/input_component.ex @@ -0,0 +1,236 @@ +defmodule ClaperWeb.Component.Input do + use ClaperWeb, :view_component + + def text(assigns) do + assigns = + assigns + |> assign_new(:required, fn -> false end) + |> assign_new(:autofocus, fn -> false end) + |> assign_new(:placeholder, fn -> false end) + |> assign_new(:readonly, fn -> false end) + |> assign_new(:labelClass, fn -> "text-gray-700" end) + |> assign_new(:fieldClass, fn -> "bg-white" end) + + ~H""" +
    + <%= label @form, @key, @name, class: "block text-sm font-medium #{@labelClass}" %> +
    + <%= text_input @form, @key, required: @required, readonly: @readonly, autofocus: @autofocus, placeholder: @placeholder, autocomplete: @key, class: "#{@fieldClass} read-only:opacity-50 outline-none shadow-base focus:ring-primary-500 focus:border-primary-500 block w-full text-lg border-gray-300 rounded-md py-4 px-3" %> +
    + <%= if Keyword.has_key?(@form.errors, @key) do %> +

    <%= error_tag @form, @key %>

    + <% end %> +
    + """ + end + + def check(assigns) do + assigns = + assigns + |> assign_new(:disabled, fn -> false end) + + ~H""" + + + """ + end + + def checked(is_checked, key, js \\ %JS{}) + + def checked(false, key, js) do + js + |> JS.remove_class("translate-x-0", + to: "#check-#{key} > span" + ) + |> JS.add_class("translate-x-6", + to: "#check-#{key} > span" + ) + |> JS.remove_class("opacity-100 ease-in duration-200", + to: "#check-#{key} > span > span" + ) + |> JS.add_class("opacity-0 ease-out duration-100", + to: "#check-#{key} > span > span" + ) + |> JS.remove_class("opacity-0 ease-out duration-100", + to: "#check-#{key} > span > span:nth-child(2)" + ) + |> JS.add_class("opacity-100 ease-in duration-200", + to: "#check-#{key} > span > span:nth-child(2)" + ) + |> JS.push("checked", value: %{key: key, value: true}) + end + + def checked(true, key, js) do + js + |> JS.remove_class("translate-x-6", + to: "#check-#{key} > span" + ) + |> JS.add_class("translate-x-0", + to: "#check-#{key} > span" + ) + |> JS.remove_class("opacity-0 ease-out duration-100", + to: "#check-#{key} > span > span" + ) + |> JS.add_class("opacity-100 ease-in duration-200", + to: "#check-#{key} > span > span" + ) + |> JS.remove_class("opacity-100 ease-in duration-200", + to: "#check-#{key} > span > span:nth-child(2)" + ) + |> JS.add_class("opacity-0 ease-out duration-100", + to: "#check-#{key} > span > span:nth-child(2)" + ) + |> JS.push("checked", value: %{key: key, value: false}) + end + + def code(assigns) do + assigns = + assigns + |> assign_new(:required, fn -> false end) + |> assign_new(:autofocus, fn -> false end) + |> assign_new(:placeholder, fn -> false end) + |> assign_new(:readonly, fn -> false end) + + ~H""" +
    + <%= label @form, @key, @name, class: "block text-sm font-medium text-gray-700" %> +
    + code + <%= text_input @form, @key, required: @required, readonly: @readonly, placeholder: @placeholder, autofocus: @autofocus, autocomplete: @key, class: "read-only:opacity-50 outline-none shadow-base focus:ring-primary-500 focus:border-primary-500 block w-full text-lg border-gray-300 rounded-md py-4 pr-3 pl-12 uppercase" %> +
    + <%= if Keyword.has_key?(@form.errors, @key) do %> +

    <%= error_tag @form, @key %>

    + <% end %> +
    + """ + end + + def date(assigns) do + assigns = + assigns + |> assign_new(:required, fn -> false end) + |> assign_new(:autofocus, fn -> false end) + + assigns = + if Map.has_key?(assigns, :dark), + do: assign(assigns, :containerTheme, "text-white"), + else: assign(assigns, :containerTheme, "text-black") + + value = Map.get(assigns.form.data, assigns.key) + + ~H""" +
    + <%= hidden_input @form, :utc_date, required: @required, "x-ref": "utc", "phx-hook": "DefaultValue", "data-default-value": "#{value}" %> + <%= text_input @form, @key, required: @required, autofocus: @autofocus, autocomplete: @key, class: "transition-all bg-transparent w-full #{@containerTheme} rounded px-3 border border-gray-500 focus:border-2 focus:border-primary-500 pt-5 pb-2 focus:outline-none input active:outline-none text-left", "x-model": "input", "x-ref": "input", "data-input": "true", "x-on:change": "$refs.utc.value = moment($refs.input.value).utc().format()" %> + <%= label @form, @key, @name, class: "label absolute mb-0 -mt-2 pt-5 pl-3 leading-tighter text-gray-500 mt-2 cursor-text transition-all left-0", "x-bind:class": "input.length > 0 ? 'text-sm -top-1.5' : 'top-1'", "x-on:click": "$refs.input.focus()", "x-on:click.away": "$refs.input.blur()" %> + <%= if Keyword.has_key?(@form.errors, @key) do %> +

    <%= error_tag @form, @key %>

    + <% end %> +
    + """ + end + + def date_range(assigns) do + assigns = + assigns + |> assign_new(:required, fn -> false end) + |> assign_new(:autofocus, fn -> false end) + |> assign_new(:placeholder, fn -> false end) + |> assign_new(:readonly, fn -> false end) + + ~H""" +
    +
    + <%= hidden_input @form, @start_date_field, "x-ref": "startDate" %> + <%= hidden_input @form, @end_date_field, "x-ref": "endDate" %> + <%= label @form, @key, @name, class: "block text-sm font-medium text-gray-700" %> +
    + <%= text_input @form, :local_date, required: @required, readonly: @readonly, class: "absolute z-0 outline-none shadow-base focus:ring-primary-500 focus:border-primary-500 block w-full text-lg border-gray-300 rounded-md py-4 px-3 read-only:opacity-50", "x-model": "date" %> + + + <%= text_input @form, @key, autofocus: @autofocus, placeholder: @placeholder, autocomplete: @key, class: "absolute z-10 bg-transparent text-transparent outline-none block w-full py-4 px-3", "data-input": "true" %> +
    + <%= if Keyword.has_key?(@form.errors, @key) do %> +

    <%= error_tag @form, @key %>

    + <% end %> +
    +
    + """ + end + + def email(assigns) do + assigns = + assigns + |> assign_new(:required, fn -> false end) + |> assign_new(:autofocus, fn -> false end) + |> assign_new(:placeholder, fn -> false end) + |> assign_new(:labelClass, fn -> "text-gray-700" end) + |> assign_new(:fieldClass, fn -> "bg-white" end) + + value = Map.get(assigns.form.data, assigns.key, "") + + ~H""" +
    + <%= label @form, @key, @name, class: "block text-sm font-medium #{@labelClass}" %> +
    + <%= email_input @form, @key, required: @required, autofocus: @autofocus, placeholder: @placeholder, autocomplete: @key, class: "#{@fieldClass} shadow-base block w-full text-lg focus:ring-primary-500 focus:ring-2 outline-none rounded-md py-4 px-3", "x-model": "input", "x-ref": "input" %> +
    + <%= if Keyword.has_key?(@form.errors, @key) do %> +

    <%= error_tag @form, @key %>

    + <% end %> +
    + """ + end + + def password(assigns) do + assigns = + assigns + |> assign_new(:required, fn -> false end) + |> assign_new(:autofocus, fn -> false end) + + assigns = + if Map.has_key?(assigns, :dark), + do: assign(assigns, :containerTheme, "text-white"), + else: assign(assigns, :containerTheme, "text-black") + + value = Map.get(assigns.form.data, assigns.key, "") + + ~H""" +
    + <%= password_input @form, @key, required: @required, autofocus: @autofocus, autocomplete: @key, class: "transition-all bg-transparent w-full #{@containerTheme} rounded px-3 border border-gray-500 focus:border-2 focus:border-primary-500 pt-5 pb-2 focus:outline-none input active:outline-none", "x-model": "input", "x-ref": "input" %> + <%= label @form, @key, @name, class: "label absolute mb-0 -mt-2 pt-5 pl-3 leading-tighter text-gray-500 mt-2 cursor-text transition-all left-0", "x-bind:class": "input.length > 0 ? 'text-sm -top-1.5' : 'top-1'", "x-on:click": "$refs.input.focus()" %> + <%= if Keyword.has_key?(@form.errors, @key) do %> +

    <%= error_tag @form, @key %>

    + <% end %> +
    + """ + end +end diff --git a/lib/claper_web/views/error_helpers.ex b/lib/claper_web/views/error_helpers.ex new file mode 100644 index 0000000..57d7a48 --- /dev/null +++ b/lib/claper_web/views/error_helpers.ex @@ -0,0 +1,47 @@ +defmodule ClaperWeb.ErrorHelpers do + @moduledoc """ + Conveniences for translating and building error messages. + """ + + use Phoenix.HTML + + @doc """ + Generates tag for inlined form input errors. + """ + def error_tag(form, field) do + Enum.map(Keyword.get_values(form.errors, field), fn error -> + content_tag(:span, translate_error(error), + class: "invalid-feedback", + phx_feedback_for: input_name(form, field) + ) + end) + 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 "is invalid" in the "errors" domain + # dgettext("errors", "is invalid") + # + # # Translate the number of files with plural rules + # dngettext("errors", "1 file", "%{count} files", count) + # + # Because the error messages we show in our forms and APIs + # are defined inside Ecto, we need to translate them dynamically. + # This requires us to call the Gettext module passing our gettext + # backend as first argument. + # + # Note we use the "errors" domain, which means translations + # should be written to the errors.po file. The :count option is + # set by Ecto and indicates we should also apply plural rules. + if count = opts[:count] do + Gettext.dngettext(ClaperWeb.Gettext, "errors", msg, msg, count, opts) + else + Gettext.dgettext(ClaperWeb.Gettext, "errors", msg, opts) + end + end +end diff --git a/lib/claper_web/views/error_view.ex b/lib/claper_web/views/error_view.ex new file mode 100644 index 0000000..cab4414 --- /dev/null +++ b/lib/claper_web/views/error_view.ex @@ -0,0 +1,16 @@ +defmodule ClaperWeb.ErrorView do + use ClaperWeb, :view + + # If you want to customize a particular status code + # for a certain format, you may uncomment below. + # def render("500.html", _assigns) do + # "Internal Server Error" + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.html" becomes + # "Not Found". + def template_not_found(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/lib/claper_web/views/event_view.ex b/lib/claper_web/views/event_view.ex new file mode 100644 index 0000000..9f1fb63 --- /dev/null +++ b/lib/claper_web/views/event_view.ex @@ -0,0 +1,15 @@ +defmodule ClaperWeb.EventView do + use ClaperWeb, :view + + def render("show.json", %{event: event}) do + %{data: render_one(event, ClaperWeb.EventView, "event.json")} + end + + def render("event.json", %{event: event}) do + %{ + uuid: event.uuid, + name: event.name, + posts: render_many(event.posts, ClaperWeb.PostView, "post.json") + } + end +end diff --git a/lib/claper_web/views/layout_view.ex b/lib/claper_web/views/layout_view.ex new file mode 100644 index 0000000..11f0f2d --- /dev/null +++ b/lib/claper_web/views/layout_view.ex @@ -0,0 +1,43 @@ +defmodule ClaperWeb.LayoutView do + use ClaperWeb, :view + + # Phoenix LiveDashboard is available only in development by default, + # so we instruct Elixir to not warn if the dashboard route is missing. + @compile {:no_warn_undefined, {Routes, :live_dashboard_path, 2}} + + def active_class(conn, path) do + current_path = Path.join(["/" | conn.path_info]) + if path == current_path do + "bg-gray-900 text-white" + else + "" + end + end + + def active_live_class(conn, path) do + if path == conn.host_uri do + "bg-gray-900 text-white" + else + "" + end + end + + def active_link(%Plug.Conn{} = conn, text, opts) do + class = [opts[:class], active_class(conn, opts[:to])] + |> Enum.filter(& &1) + |> Enum.join(" ") + opts = opts + |> Keyword.put(:class, class) + link(text, opts) + end + + def active_link(%Phoenix.LiveView.Socket{} = conn, text, opts) do + class = [opts[:class], active_live_class(conn, opts[:to])] + |> Enum.filter(& &1) + |> Enum.join(" ") + opts = opts + |> Keyword.put(:class, class) + live_patch(text, opts) + end + +end diff --git a/lib/claper_web/views/page_view.ex b/lib/claper_web/views/page_view.ex new file mode 100644 index 0000000..409b90f --- /dev/null +++ b/lib/claper_web/views/page_view.ex @@ -0,0 +1,3 @@ +defmodule ClaperWeb.PageView do + use ClaperWeb, :view +end diff --git a/lib/claper_web/views/post_view.ex b/lib/claper_web/views/post_view.ex new file mode 100644 index 0000000..4ca4e6b --- /dev/null +++ b/lib/claper_web/views/post_view.ex @@ -0,0 +1,25 @@ +defmodule ClaperWeb.PostView do + use ClaperWeb, :view + + def render("index.json", %{posts: posts}) do + %{data: render_many(posts, ClaperWeb.PostView, "post.json")} + end + + def render("post.json", %{post: %{user: %{uuid: _} = user} = post}) do + %{ + uuid: post.uuid, + body: post.body, + inserted_at: post.inserted_at, + user: render_one(user, ClaperWeb.UserView, "user.json") + } + end + + def render("post.json", %{post: %{attendee: %{uuid: _} = attendee} = post}) do + %{ + uuid: post.uuid, + body: post.body, + inserted_at: post.inserted_at, + attendee: render_one(attendee, ClaperWeb.AttendeeView, "attendee.json") + } + end +end diff --git a/lib/claper_web/views/user_confirmation_view.ex b/lib/claper_web/views/user_confirmation_view.ex new file mode 100644 index 0000000..eded9db --- /dev/null +++ b/lib/claper_web/views/user_confirmation_view.ex @@ -0,0 +1,3 @@ +defmodule ClaperWeb.UserConfirmationView do + use ClaperWeb, :view +end diff --git a/lib/claper_web/views/user_notifier_view.ex b/lib/claper_web/views/user_notifier_view.ex new file mode 100644 index 0000000..981728b --- /dev/null +++ b/lib/claper_web/views/user_notifier_view.ex @@ -0,0 +1,5 @@ +defmodule ClaperWeb.UserNotifierView do + use Phoenix.View, root: "lib/claper_web/templates" + import ClaperWeb.Gettext + use Phoenix.HTML +end diff --git a/lib/claper_web/views/user_registration_view.ex b/lib/claper_web/views/user_registration_view.ex new file mode 100644 index 0000000..c313015 --- /dev/null +++ b/lib/claper_web/views/user_registration_view.ex @@ -0,0 +1,10 @@ +defmodule ClaperWeb.UserRegistrationView do + use ClaperWeb, :view + + def render("user.json", %{user_registration: user}) do + %{ + email: user.email, + name: user.full_name + } + end +end diff --git a/lib/claper_web/views/user_reset_password_view.ex b/lib/claper_web/views/user_reset_password_view.ex new file mode 100644 index 0000000..e36e205 --- /dev/null +++ b/lib/claper_web/views/user_reset_password_view.ex @@ -0,0 +1,3 @@ +defmodule ClaperWeb.UserResetPasswordView do + use ClaperWeb, :view +end diff --git a/lib/claper_web/views/user_session_view.ex b/lib/claper_web/views/user_session_view.ex new file mode 100644 index 0000000..4e9baf4 --- /dev/null +++ b/lib/claper_web/views/user_session_view.ex @@ -0,0 +1,3 @@ +defmodule ClaperWeb.UserSessionView do + use ClaperWeb, :view +end diff --git a/lib/claper_web/views/user_settings_view.ex b/lib/claper_web/views/user_settings_view.ex new file mode 100644 index 0000000..3a0be0a --- /dev/null +++ b/lib/claper_web/views/user_settings_view.ex @@ -0,0 +1,3 @@ +defmodule ClaperWeb.UserSettingsView do + use ClaperWeb, :view +end diff --git a/lib/claper_web/views/user_view.ex b/lib/claper_web/views/user_view.ex new file mode 100644 index 0000000..80d7acd --- /dev/null +++ b/lib/claper_web/views/user_view.ex @@ -0,0 +1,11 @@ +defmodule ClaperWeb.UserView do + use ClaperWeb, :view + + + def render("user.json", %{user: user}) do + %{ + uuid: user.uuid, + email: user.email + } + end +end diff --git a/lib/utils/file_upload.ex b/lib/utils/file_upload.ex new file mode 100644 index 0000000..48f8101 --- /dev/null +++ b/lib/utils/file_upload.ex @@ -0,0 +1,19 @@ +defmodule Utils.FileUpload do + + import Mogrify + + def upload(type, path, old_path) when is_atom(type) do + remove_old_file(old_path) + dest = Path.join([:code.priv_dir(:claper), "static", "uploads", Atom.to_string(type), Path.basename(path)]) + open(path) |> resize_to_fill("100x100") |> save(in_place: true) + File.cp!(path, dest) + "/uploads/#{Atom.to_string(type)}/#{Path.basename(dest)}" + end + + defp remove_old_file(old_path) do + if old_path do + old_file = Path.join([:code.priv_dir(:claper), "static", old_path]) + if File.exists?(old_file), do: File.rm(old_file) + end + end +end diff --git a/lib/utils/pagination.ex b/lib/utils/pagination.ex new file mode 100644 index 0000000..e69de29 diff --git a/lib/utils/simple_s3_upload.ex b/lib/utils/simple_s3_upload.ex new file mode 100644 index 0000000..b7d0af7 --- /dev/null +++ b/lib/utils/simple_s3_upload.ex @@ -0,0 +1,127 @@ +defmodule SimpleS3Upload do + @moduledoc """ + Dependency-free S3 Form Upload using HTTP POST sigv4 + + https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-post-example.html + """ + + @doc """ + Signs a form upload. + + The configuration is a map which must contain the following keys: + + * `:region` - The AWS region, such as "us-east-1" + * `:access_key_id` - The AWS access key id + * `:secret_access_key` - The AWS secret access key + + + Returns a map of form fields to be used on the client via the JavaScript `FormData` API. + + ## Options + + * `:key` - The required key of the object to be uploaded. + * `:max_file_size` - The required maximum allowed file size in bytes. + * `:content_type` - The required MIME type of the file to be uploaded. + * `:expires_in` - The required expiration time in milliseconds from now + before the signed upload expires. + + ## Examples + + config = %{ + region: "us-east-1", + access_key_id: System.fetch_env!("AWS_ACCESS_KEY_ID"), + secret_access_key: System.fetch_env!("AWS_SECRET_ACCESS_KEY") + } + + {:ok, fields} = + SimpleS3Upload.sign_form_upload(config, "my-bucket", + key: "public/my-file-name", + content_type: "image/png", + max_file_size: 10_000, + expires_in: :timer.hours(1) + ) + + """ + def sign_form_upload(config, bucket, opts) do + key = Keyword.fetch!(opts, :key) + max_file_size = Keyword.fetch!(opts, :max_file_size) + content_type = Keyword.fetch!(opts, :content_type) + expires_in = Keyword.fetch!(opts, :expires_in) + + expires_at = DateTime.add(DateTime.utc_now(), expires_in, :millisecond) + amz_date = amz_date(expires_at) + credential = credential(config, expires_at) + + encoded_policy = + Base.encode64(""" + { + "expiration": "#{DateTime.to_iso8601(expires_at)}", + "conditions": [ + {"bucket": "#{bucket}"}, + ["eq", "$key", "#{key}"], + {"acl": "public-read"}, + ["eq", "$Content-Type", "#{content_type}"], + ["content-length-range", 0, #{max_file_size}], + {"x-amz-server-side-encryption": "AES256"}, + {"x-amz-credential": "#{credential}"}, + {"x-amz-algorithm": "AWS4-HMAC-SHA256"}, + {"x-amz-date": "#{amz_date}"} + ] + } + """) + + fields = %{ + "key" => key, + "acl" => "public-read", + "content-type" => content_type, + "x-amz-server-side-encryption" => "AES256", + "x-amz-credential" => credential, + "x-amz-algorithm" => "AWS4-HMAC-SHA256", + "x-amz-date" => amz_date, + "policy" => encoded_policy, + "x-amz-signature" => signature(config, expires_at, encoded_policy) + } + + {:ok, fields} + end + + defp amz_date(time) do + time + |> NaiveDateTime.to_iso8601() + |> String.split(".") + |> List.first() + |> String.replace("-", "") + |> String.replace(":", "") + |> Kernel.<>("Z") + end + + defp credential(%{} = config, %DateTime{} = expires_at) do + "#{config.access_key_id}/#{short_date(expires_at)}/#{config.region}/s3/aws4_request" + end + + defp signature(config, %DateTime{} = expires_at, encoded_policy) do + config + |> signing_key(expires_at, "s3") + |> sha256(encoded_policy) + |> Base.encode16(case: :lower) + end + + defp signing_key(%{} = config, %DateTime{} = expires_at, service) when service in ["s3"] do + amz_date = short_date(expires_at) + %{secret_access_key: secret, region: region} = config + + ("AWS4" <> secret) + |> sha256(amz_date) + |> sha256(region) + |> sha256(service) + |> sha256("aws4_request") + end + + defp short_date(%DateTime{} = expires_at) do + expires_at + |> amz_date() + |> String.slice(0..7) + end + + defp sha256(secret, msg), do: :crypto.mac(:hmac, :sha256, secret, msg) +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..3502f54 --- /dev/null +++ b/mix.exs @@ -0,0 +1,88 @@ +defmodule Claper.MixProject do + use Mix.Project + + def project do + [ + app: :claper, + version: "1.0.0", + elixir: "~> 1.12", + elixirc_paths: elixirc_paths(Mix.env()), + compilers: [:gettext] ++ Mix.compilers(), + 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: {Claper.Application, []}, + extra_applications: [:logger, :runtime_tools, :ssl, :porcelain] + ] + 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 + [ + {:ex_aws, "~> 2.2"}, + {:ex_aws_s3, "~> 2.3"}, + {:ex_doc, "~> 0.27", only: :dev, runtime: false}, + {:bcrypt_elixir, "~> 2.0"}, + {:phoenix, "~> 1.6.2"}, + {:phoenix_ecto, "~> 4.4"}, + {:ecto_sql, "~> 3.6"}, + {:postgrex, ">= 0.0.0"}, + {:phoenix_html, "~> 3.0"}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:phoenix_live_view, "~> 0.17.0"}, + {:phoenix_swoosh, "~> 1.0"}, + {:floki, ">= 0.30.0", only: :test}, + {:phoenix_live_dashboard, "~> 0.6"}, + {:esbuild, "~> 0.2", runtime: Mix.env() == :dev}, + {:dart_sass, "~> 0.4", runtime: Mix.env() == :dev}, + {:swoosh, "~> 1.3"}, + {:finch, "~> 0.8"}, + {:telemetry_metrics, "~> 0.6"}, + {:telemetry_poller, "~> 1.0"}, + {:gettext, "~> 0.18"}, + {:jason, "~> 1.2"}, + {:sweet_xml, "~> 0.7.1"}, + {:plug_cowboy, "~> 2.5"}, + {:hashids, "~> 2.0"}, + {:mogrify, "~> 0.9.1"}, + {:libcluster, "~> 3.3"}, + {:porcelain, "~> 2.0"}, + {:hackney, "~> 1.18"} + ] + 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"], + "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.deploy": [ + "cmd --cd assets npm install && npm run deploy", + "esbuild default --minify", + "sass default --no-source-map --style=compressed", + "phx.digest" + ] + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..3641066 --- /dev/null +++ b/mix.lock @@ -0,0 +1,69 @@ +%{ + "bcrypt_elixir": {:hex, :bcrypt_elixir, "2.3.1", "5114d780459a04f2b4aeef52307de23de961b69e13a5cd98a911e39fda13f420", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "42182d5f46764def15bf9af83739e3bf4ad22661b1c34fc3e88558efced07279"}, + "castore": {:hex, :castore, "0.1.16", "2675f717adc700475345c5512c381ef9273eb5df26bdd3f8c13e2636cf4cc175", [:mix], [], "hexpm", "28ed2c43d83b5c25d35c51bc0abf229ac51359c170cba76171a462ced2e4b651"}, + "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, + "comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"}, + "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, + "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, + "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, + "dart_sass": {:hex, :dart_sass, "0.4.0", "50a0898faaa0b6584ee1a690f3aa02069b7aad7e873ddc4517a482ea39f3b476", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "27b7f4624fafdeb419d283f62425f6c623654f312455f53515ee6ef2144bf8de"}, + "db_connection": {:hex, :db_connection, "2.4.2", "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668"}, + "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.26", "f4291134583f373c7d8755566122908eb9662df4c4b63caa66a0eabe06569b0a", [:mix], [], "hexpm", "48d460899f8a0c52c5470676611c01f64f3337bad0b26ddab43648428d94aabc"}, + "ecto": {:hex, :ecto, "3.7.2", "44c034f88e1980754983cc4400585970b4206841f6f3780967a65a9150ef09a8", [:mix], [{:decimal, "~> 1.6 or ~> 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", "a600da5772d1c31abbf06f3e4a1ffb150e74ed3e2aa92ff3cee95901657a874e"}, + "ecto_sql": {:hex, :ecto_sql, "3.7.2", "55c60aa3a06168912abf145c6df38b0295c34118c3624cf7a6977cd6ce043081", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.4.0 or ~> 0.5.0 or ~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 0.16.0 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", "3c218ea62f305dcaef0b915fb56583195e7b91c91dcfb006ba1f669bfacbff2a"}, + "elixir_make": {:hex, :elixir_make, "0.6.3", "bc07d53221216838d79e03a8019d0839786703129599e9619f4ab74c8c096eac", [:mix], [], "hexpm", "f5cbd651c5678bcaabdbb7857658ee106b12509cd976c2c2fca99688e1daf716"}, + "esbuild": {:hex, :esbuild, "0.4.0", "9f17db148aead4cf1e6e6a584214357287a93407b5fb51a031f122b61385d4c2", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "b61e4e6b92ffe45e4ee4755a22de6211a67c67987dc02afb35a425a0add1d447"}, + "ex_aws": {:hex, :ex_aws, "2.2.10", "064139724335b00b6665af7277189afc9ed507791b1ccf2698dadc7c8ad892e8", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "98acb63f74b2f0822be219c5c2f0e8d243c2390f5325ad0557b014d3360da47e"}, + "ex_aws_s3": {:hex, :ex_aws_s3, "2.3.2", "92a63b72d763b488510626d528775b26831f5c82b066a63a3128054b7a09de28", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "b235b27131409bcc293c343bf39f1fbdd32892aa237b3f13752e914dc2979960"}, + "ex_doc": {:hex, :ex_doc, "0.28.4", "001a0ea6beac2f810f1abc3dbf4b123e9593eaa5f00dd13ded024eae7c523298", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "bf85d003dd34911d89c8ddb8bda1a958af3471a274a4c2150a9c01c78ac3f8ed"}, + "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "finch": {:hex, :finch, "0.10.2", "9ad27d68270d879f73f26604bb2e573d40f29bf0e907064a9a337f90a16a0312", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dd8b11b282072cec2ef30852283949c248bd5d2820c88d8acc89402b81db7550"}, + "floki": {:hex, :floki, "0.32.0", "f915dc15258bc997d49be1f5ef7d3992f8834d6f5695270acad17b41f5bcc8e2", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "1c5a91cae1fd8931c26a4826b5e2372c284813904c8bacb468b5de39c7ececbd"}, + "gettext": {:hex, :gettext, "0.19.1", "564953fd21f29358e68b91634799d9d26989f8d039d7512622efb3c3b1c97892", [:mix], [], "hexpm", "10c656c0912b8299adba9b061c06947511e3f109ab0d18b44a866a4498e77222"}, + "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, + "hashids": {:hex, :hashids, "2.0.5", "d9839924c8221b954da8b110eda3e59c2c03df0389bac6e7d0e535f937033df1", [:mix], [], "hexpm", "ef47d8679f20d7bea59d0d49c202258c89f61b9b741bd3dceef2c1985cf95554"}, + "honeybadger": {:hex, :honeybadger, "0.18.1", "f61f71147d9e6ce8cdc6114e5df5eed4d2daa32dd15308267a1dacf5fa88b1e9", [:mix], [{:ecto, ">= 2.0.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:hackney, "~> 1.1", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.0.0 and < 2.0.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, ">= 1.0.0 and < 2.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "693e14b1b7d254dd75f977240c208d6b69cc1cbdd515bdd5b8b1738a1baf5fd5"}, + "hpax": {:hex, :hpax, "0.1.1", "2396c313683ada39e98c20a75a82911592b47e5c24391363343bde74f82396ca", [:mix], [], "hexpm", "0ae7d5a0b04a8a60caf7a39fcf3ec476f35cc2cc16c05abea730d3ce6ac6c826"}, + "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, + "libcluster": {:hex, :libcluster, "3.3.1", "e7a4875cd1290cee7a693d6bd46076863e9e433708b01339783de6eff5b7f0aa", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b575ca63c1cd84e01f3fa0fc45e6eb945c1ee7ae8d441d33def999075e9e5398"}, + "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, + "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, + "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "mint": {:hex, :mint, "1.4.1", "49b3b6ea35a9a38836d2ad745251b01ca9ec062f7cb66f546bf22e6699137126", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "cd261766e61011a9079cccf8fa9d826e7a397c24fbedf0e11b49312bea629b58"}, + "mogrify": {:hex, :mogrify, "0.9.1", "a26f107c4987477769f272bd0f7e3ac4b7b75b11ba597fd001b877beffa9c068", [:mix], [], "hexpm", "134edf189337d2125c0948bf0c228fdeef975c594317452d536224069a5b7f05"}, + "nimble_options": {:hex, :nimble_options, "0.4.0", "c89babbab52221a24b8d1ff9e7d838be70f0d871be823165c94dd3418eea728f", [:mix], [], "hexpm", "e6701c1af326a11eea9634a3b1c62b475339ace9456c1a23ec3bc9a847bca02d"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, + "nimble_pool": {:hex, :nimble_pool, "0.2.6", "91f2f4c357da4c4a0a548286c84a3a28004f68f05609b4534526871a22053cde", [:mix], [], "hexpm", "1c715055095d3f2705c4e236c18b618420a35490da94149ff8b580a2144f653f"}, + "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, + "phoenix": {:hex, :phoenix, "1.6.6", "281c8ce8dccc9f60607346b72cdfc597c3dde134dd9df28dff08282f0b751754", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "807bd646e64cd9dc83db016199715faba72758e6db1de0707eef0a2da4924364"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, + "phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.6.5", "1495bb014be12c9a9252eca04b9af54246f6b5c1e4cd1f30210cd00ec540cf8e", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.3", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.17.7", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "ef4fa50dd78364409039c99cf6f98ab5209b4c5f8796c17f4db118324f0db852"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.3", "3a53772a6118d5679bf50fc1670505a290e32a1d195df9e069d8c53ab040c054", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "766796676e5f558dbae5d1bdb066849673e956005e3730dfd5affd7a6da4abac"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "0.17.7", "05a42377075868a678d446361effba80cefef19ab98941c01a7a4c7560b29121", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.9 or ~> 1.6.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "25eaf41028eb351b90d4f69671874643a09944098fefd0d01d442f40a6091b6f"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, + "phoenix_swoosh": {:hex, :phoenix_swoosh, "1.0.1", "0db6eb6405a6b06cae4fdf4144659b3f4fee4553e2856fe8a53ba12e9fb21a74", [:mix], [{:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:hackney, "~> 1.10", [hex: :hackney, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.5", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "e34890004baec08f0fa12bd8c77bf64bfb4156b84a07fb79da9322fa94bc3781"}, + "phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"}, + "plug": {:hex, :plug, "1.13.4", "addb6e125347226e3b11489e23d22a60f7ab74786befb86c14f94fb5f23ca9a4", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "06114c1f2a334212fe3ae567dbb3b1d29fd492c1a09783d52f3d489c1a6f4cf2"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, + "porcelain": {:hex, :porcelain, "2.0.3", "2d77b17d1f21fed875b8c5ecba72a01533db2013bd2e5e62c6d286c029150fdc", [:mix], [], "hexpm", "dc996ab8fadbc09912c787c7ab8673065e50ea1a6245177b0c24569013d23620"}, + "postgrex": {:hex, :postgrex, "0.16.2", "0f83198d0e73a36e8d716b90f45f3bde75b5eebf4ade4f43fa1f88c90a812f74", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {: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]}], "hexpm", "a9ea589754d9d4d076121090662b7afe155b374897a6550eb288f11d755acfa0"}, + "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, + "stripity_stripe": {:hex, :stripity_stripe, "2.13.0", "b9ea806fcf46e85232b75f2145c34770b17faa44c59cdd13ff493aaa6e84b4a9", [:mix], [{:hackney, "~> 1.15", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}, {:uri_query, "~> 0.1.2", [hex: :uri_query, repo: "hexpm", optional: false]}], "hexpm", "d6931ed9816552320f95428fd997edf15e99a913ca78fc4342d5516b98f42476"}, + "sweet_xml": {:hex, :sweet_xml, "0.7.2", "4729f997286811fabdd8288f8474e0840a76573051062f066c4b597e76f14f9f", [:mix], [], "hexpm", "6894e68a120f454534d99045ea3325f7740ea71260bc315f82e29731d570a6e8"}, + "swoosh": {:hex, :swoosh, "1.6.3", "598d3f07641004bedb3eede40057760ae18be1073cff72f079ca1e1fc9cd97b9", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, 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]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "81ff9d7c7c4005a57465a7eb712edd71db51829aef94c8a34c30c5b9e9964adf"}, + "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "uri_query": {:hex, :uri_query, "0.1.2", "ae35b83b472f3568c2c159eee3f3ccf585375d8a94fb5382db1ea3589e75c3b4", [:mix], [], "hexpm", "e3bc81816c98502c36498b9b2f239b89c71ce5eadfff7ceb2d6c0a2e6ae2ea0c"}, +} diff --git a/phoenix_static_buildpack.config b/phoenix_static_buildpack.config new file mode 100644 index 0000000..0ce5c5f --- /dev/null +++ b/phoenix_static_buildpack.config @@ -0,0 +1 @@ +node_version=14.19.0 diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot new file mode 100644 index 0000000..04c1ec4 --- /dev/null +++ b/priv/gettext/default.pot @@ -0,0 +1,740 @@ +## This file 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 as no +## effect: edit them in PO (.po) files instead. +msgid "" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/show.ex:35 +#: lib/claper_web/live/user_settings_live/show.html.heex:5 +#: lib/claper_web/templates/layout/_user_menu.html.heex:2 +msgid "Settings" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/form_component.html.heex:7 +#: lib/claper_web/live/user_settings_live/show.html.heex:26 +#: lib/claper_web/templates/user_registration/new.html.heex:22 +#: lib/claper_web/templates/user_session/new.html.heex:34 +msgid "Email" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_registration/new.html.heex:13 +msgid "Join the Claper experience" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_registration/new.html.heex:19 +msgid "Oops, check that all fields are filled in correctly." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/show.html.heex:53 +msgid "Change" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:167 +msgid "Code" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/show.html.heex:48 +msgid "Email address" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/layout/_user_menu.html.heex:5 +msgid "Logout" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/show.html.heex:38 +msgid "Personal informations" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:28 +msgid "Upgrade" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_registration/confirm.html.heex:14 +msgid "We already sent you an email to login, please retry in 5 minutes." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_registration/confirm.html.heex:17 +msgid "We sent you an email at" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/show.html.heex:41 +msgid "Your personal informations only visible by you" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/presenter.html.heex:15 +#: lib/claper_web/live/event_live/show.html.heex:127 +msgid "Or go to Claper.co and use the code:" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/show.html.heex:114 +msgid "days" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/show.html.heex:115 +msgid "hours" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/show.html.heex:116 +msgid "minutes" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/show.html.heex:49 +msgid "Be the first to react !" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:76 +#: lib/claper_web/live/event_live/join.ex:42 +#: lib/claper_web/live/event_live/join.html.heex:62 +msgid "Join" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/index.ex:82 +#: lib/claper_web/live/event_live/join.html.heex:15 +#: lib/claper_web/live/event_live/join.html.heex:26 +msgid "Dashboard" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/post_component.ex:43 +msgid "Host" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_session/new.html.heex:38 +msgid "Send link by email" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/show.html.heex:117 +msgid "seconds" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/index.html.heex:39 +msgid "Create your first presentation" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_session/new.html.heex:34 +msgid "Enter your address email..." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:46 +#: lib/claper_web/live/event_live/manage.html.heex:29 +msgid "Finish on" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:30 +msgid "Finished" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:56 +msgid "Finished on" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:20 +msgid "In progress" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:25 +msgid "Incoming" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/show.html.heex:16 +msgid "Leave" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/index.html.heex:10 +msgid "My presentations" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:163 +msgid "Name of your presentation" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/presenter.html.heex:12 +#: lib/claper_web/live/event_live/show.html.heex:124 +msgid "Scan to interact in real-time" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:51 +msgid "Starting on" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.ex:210 +msgid "Updated successfully" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:171 +msgid "When your presentation will be available ?" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/join.html.heex:44 +msgid "Return to your last presentation" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_session/new.html.heex:23 +msgid "It's time to empower your presentations." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/error/404.html.heex:36 +#: lib/claper_web/templates/error/500.html.heex:37 +#: lib/claper_web/templates/user_registration/confirm.html.heex:25 +msgid "Return to home" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.ex:182 +msgid "Created successfully" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.ex:22 +#: lib/claper_web/live/event_live/presenter.ex:21 +#: lib/claper_web/live/event_live/show.ex:24 +msgid "Presentation doesn't exist" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:82 +#: lib/claper_web/live/event_live/event_card_component.ex:95 +#: lib/claper_web/live/event_live/index.ex:65 +msgid "Edit" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:13 +#: lib/claper_web/live/event_live/form_component.html.heex:20 +#: lib/claper_web/live/event_live/index.ex:71 +#: lib/claper_web/live/event_live/index.html.heex:23 +#: lib/claper_web/live/poll_live/form_component.html.heex:44 +msgid "Create" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:124 +#: lib/claper_web/live/event_live/form_component.html.heex:25 +#: lib/claper_web/live/event_live/manage.html.heex:185 +#: lib/claper_web/live/event_live/post_component.ex:13 +#: lib/claper_web/live/event_live/post_component.ex:52 +#: lib/claper_web/live/poll_live/form_component.html.heex:48 +msgid "Delete" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:12 +#: lib/claper_web/live/event_live/form_component.html.heex:19 +#: lib/claper_web/live/poll_live/form_component.html.heex:45 +#: lib/claper_web/live/user_settings_live/form_component.html.heex:9 +#: lib/claper_web/live/user_settings_live/show.html.heex:28 +msgid "Save" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/form_component.ex:34 +#: lib/claper_web/live/user_settings_live/show.ex:56 +msgid "A link to confirm your email change has been sent to the new address." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/show.ex:29 +msgid "Change the email address you want associated with your account." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/show.ex:26 +msgid "Update your email" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/notifiers/user_notifier.ex:9 +#: lib/claper_web/templates/user_notifier/magic.html.heex:10 +msgid "Connect to Claper" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/magic.html.heex:18 +msgid "ACCESS TO MY ACCOUNT" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/notifiers/user_notifier.ex:25 +msgid "Update email instructions" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/change.html.heex:18 +msgid "CONFIRM EMAIL" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/change.html.heex:10 +msgid "Confirm email" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/change.html.heex:20 +#: lib/claper_web/templates/user_notifier/magic.html.heex:20 +msgid "If you didn't create an account with us, please ignore this." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/magic.html.heex:14 +msgid "You can log into your account by clicking here." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:25 +#: lib/claper_web/live/event_live/post_component.ex:13 +#: lib/claper_web/live/event_live/post_component.ex:52 +msgid "Are you sure?" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:106 +msgid "Presentation attached" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:74 +msgid "Presentation uploaded" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:78 +#: lib/claper_web/live/event_live/form_component.html.heex:139 +#: lib/claper_web/live/event_live/form_component.html.heex:203 +#: lib/claper_web/live/event_live/form_component.html.heex:217 +msgid "Remove" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:32 +#: lib/claper_web/live/event_live/form_component.html.heex:88 +msgid "Select your presentation" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:43 +msgid "Upload a file" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:47 +msgid "or drag and drop" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.ex:219 +msgid "You have selected an incorrect file type" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.ex:218 +msgid "Your file is too large" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:111 +msgid "Change file" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:133 +msgid "Presentation replaced" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:83 +msgid "Edit poll" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:82 +msgid "New poll" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/poll_live/form_component.html.heex:11 +msgid "Title of your poll" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.ex:220 +msgid "Upload failed" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:71 +msgid "Add poll to know opinion of your public." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:70 +#: lib/claper_web/live/event_live/manage.html.heex:123 +msgid "Poll" +msgstr "" + +#, elixir-format, ex-autogen +#: lib/claper_web/live/poll_live/form_component.html.heex:17 +msgid "Choice %{count}" +msgid_plural "Choice %{count}" +msgstr[0] "" +msgstr[1] "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/poll_component.ex:27 +msgid "Current poll" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/poll_component.ex:14 +msgid "See current poll" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/poll_component.ex:67 +#: lib/claper_web/live/event_live/poll_component.ex:69 +msgid "Vote" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/index.html.heex:49 +msgid "Invited presentations" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:199 +#: lib/claper_web/live/event_live/form_component.html.heex:210 +msgid "User email address" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:72 +msgid "Present/Customize" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:130 +msgid "Active" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:115 +msgid "Changing your file will remove all interaction elements like polls associated." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:171 +msgid "Messages from attendees will appear here." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:229 +msgid "On screen settings" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:105 +msgid "Processing your file..." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/poll_live/form_component.html.heex:48 +msgid "This will delete all responses associated and the poll itself, are you sure?" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:40 +msgid "Start" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:35 +msgid "Press F in the presentation window to enable fullscreen" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/show.html.heex:75 +msgid "Ask, comment..." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:243 +msgid "Active poll results" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:233 +msgid "Instructions" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:238 +#: lib/claper_web/live/stat_live/index.html.heex:41 +msgid "Messages" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:133 +msgid "Set active" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:187 +msgid "Add facilitator" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/error/404.html.heex:32 +msgid "Oops, page doesn't exist." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/error/500.html.heex:32 +msgid "The site is under maintenance, we'll be back very soon!" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:180 +msgid "Facilitators can present and manage interactions" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/change.html.heex:29 +#: lib/claper_web/templates/user_notifier/magic.html.heex:29 +msgid "If you’re having trouble with the button above, copy and paste the URL below into your web browser" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/change.html.heex:14 +msgid "You can change your email by visiting the URL below" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:147 +msgid "Add interaction" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:181 +#: lib/claper_web/live/event_live/manage.html.heex:183 +msgid "Blocking this user will delete all his messages and he will not be able to join again, confirm ?" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/show.ex:50 +#: lib/claper_web/live/event_live/show.ex:188 +#: lib/claper_web/live/event_live/show.ex:203 +msgid "You have been banned from this event" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:181 +#: lib/claper_web/live/event_live/manage.html.heex:183 +msgid "Ban" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_registration/confirm.html.heex:17 +msgid ", click on the provided link to connect (check your spam !)" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:17 +msgid "Export your current presentation to PDF from your favorite slide presentation software (PowerPoint, etc)" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:20 +msgid "Wait few minutes for your file to be processed" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:19 +msgid "Choose a name for your event, a code for your attendees to join and dates when your attendees could start interacting" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:22 +msgid "Click Start to open your presentation and move the window on the big screen" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:21 +msgid "Click on Present/Customize to add interaction on your slides" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:14 +msgid "Congrats! You've taken the first step to improving your presentations. Here are the next steps to create step up your presentations with Claper:" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:23 +msgid "Enjoy ! ✨" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:30 +msgid "If you have any questions, feel free to email us. We also offer live chat during business hours." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_registration/confirm.html.heex:19 +msgid "We sent you an email, click on the provided link to connect (check your spam !)" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:10 +msgid "Welcome !" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:18 +msgid "Click on the create button on your dashboard" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/notifiers/user_notifier.ex:17 +msgid "Next steps to boost your presentations" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:25 +msgid "To have more than 25 attendees and create more than one presentation by month, upgrade your plan." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:45 +msgid "from %{count} people" +msgid_plural "from %{count} peoples" +msgstr[0] "" +msgstr[1] "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:62 +msgid "from %{count} poll" +msgid_plural "from %{count} polls" +msgstr[0] "" +msgstr[1] "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:58 +msgid "Average voters" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:14 +msgid "Event" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:86 +msgid "Interactions history" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:122 +msgid "No messages has been sent" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:119 +msgid "Report" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:124 +msgid "This will delete all data related to your event, this cannot be undone. Confirm ?" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:28 +msgid "attendee" +msgid_plural "attendees" +msgstr[0] "" +msgstr[1] "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:24 +msgid "Audience peak" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:75 +msgid "Engagement rate" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:91 +msgid "Error when processing the file" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:63 +msgid "Error when processing the new file" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:49 +msgid "PDF, PPT, PPTX up to 15MB" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/join.html.heex:13 +#: lib/claper_web/live/event_live/join.html.heex:24 +msgid "About" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/join.html.heex:17 +#: lib/claper_web/live/event_live/join.html.heex:28 +msgid "Login" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_session/new.html.heex:25 +msgid "Connect to your account" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po new file mode 100644 index 0000000..493d7f9 --- /dev/null +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -0,0 +1,741 @@ +## "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\n" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/show.ex:35 +#: lib/claper_web/live/user_settings_live/show.html.heex:5 +#: lib/claper_web/templates/layout/_user_menu.html.heex:2 +msgid "Settings" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/form_component.html.heex:7 +#: lib/claper_web/live/user_settings_live/show.html.heex:26 +#: lib/claper_web/templates/user_registration/new.html.heex:22 +#: lib/claper_web/templates/user_session/new.html.heex:34 +msgid "Email" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_registration/new.html.heex:13 +msgid "Join the Claper experience" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_registration/new.html.heex:19 +msgid "Oops, check that all fields are filled in correctly." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/show.html.heex:53 +msgid "Change" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:167 +msgid "Code" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/show.html.heex:48 +msgid "Email address" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/templates/layout/_user_menu.html.heex:5 +msgid "Logout" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/show.html.heex:38 +msgid "Personal informations" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:28 +msgid "Upgrade" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_registration/confirm.html.heex:14 +msgid "We already sent you an email to login, please retry in 5 minutes." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_registration/confirm.html.heex:17 +msgid "We sent you an email at" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/show.html.heex:41 +msgid "Your personal informations only visible by you" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/presenter.html.heex:15 +#: lib/claper_web/live/event_live/show.html.heex:127 +msgid "Or go to Claper.co and use the code:" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/show.html.heex:114 +msgid "days" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/show.html.heex:115 +msgid "hours" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/show.html.heex:116 +msgid "minutes" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/show.html.heex:49 +msgid "Be the first to react !" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:76 +#: lib/claper_web/live/event_live/join.ex:42 +#: lib/claper_web/live/event_live/join.html.heex:62 +msgid "Join" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/index.ex:82 +#: lib/claper_web/live/event_live/join.html.heex:15 +#: lib/claper_web/live/event_live/join.html.heex:26 +msgid "Dashboard" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/post_component.ex:43 +msgid "Host" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_session/new.html.heex:38 +msgid "Send link by email" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/show.html.heex:117 +msgid "seconds" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/index.html.heex:39 +msgid "Create your first presentation" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_session/new.html.heex:34 +msgid "Enter your address email..." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:46 +#: lib/claper_web/live/event_live/manage.html.heex:29 +msgid "Finish on" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:30 +msgid "Finished" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:56 +msgid "Finished on" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:20 +msgid "In progress" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:25 +msgid "Incoming" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/show.html.heex:16 +msgid "Leave" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/index.html.heex:10 +msgid "My presentations" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/form_component.html.heex:163 +msgid "Name of your presentation" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/presenter.html.heex:12 +#: lib/claper_web/live/event_live/show.html.heex:124 +msgid "Scan to interact in real-time" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:51 +msgid "Starting on" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.ex:210 +msgid "Updated successfully" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:171 +msgid "When your presentation will be available ?" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/join.html.heex:44 +msgid "Return to your last presentation" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/templates/user_session/new.html.heex:23 +msgid "It's time to empower your presentations." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/error/404.html.heex:36 +#: lib/claper_web/templates/error/500.html.heex:37 +#: lib/claper_web/templates/user_registration/confirm.html.heex:25 +msgid "Return to home" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/form_component.ex:182 +msgid "Created successfully" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.ex:22 +#: lib/claper_web/live/event_live/presenter.ex:21 +#: lib/claper_web/live/event_live/show.ex:24 +msgid "Presentation doesn't exist" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:82 +#: lib/claper_web/live/event_live/event_card_component.ex:95 +#: lib/claper_web/live/event_live/index.ex:65 +msgid "Edit" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:13 +#: lib/claper_web/live/event_live/form_component.html.heex:20 +#: lib/claper_web/live/event_live/index.ex:71 +#: lib/claper_web/live/event_live/index.html.heex:23 +#: lib/claper_web/live/poll_live/form_component.html.heex:44 +msgid "Create" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:124 +#: lib/claper_web/live/event_live/form_component.html.heex:25 +#: lib/claper_web/live/event_live/manage.html.heex:185 +#: lib/claper_web/live/event_live/post_component.ex:13 +#: lib/claper_web/live/event_live/post_component.ex:52 +#: lib/claper_web/live/poll_live/form_component.html.heex:48 +msgid "Delete" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:12 +#: lib/claper_web/live/event_live/form_component.html.heex:19 +#: lib/claper_web/live/poll_live/form_component.html.heex:45 +#: lib/claper_web/live/user_settings_live/form_component.html.heex:9 +#: lib/claper_web/live/user_settings_live/show.html.heex:28 +msgid "Save" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/form_component.ex:34 +#: lib/claper_web/live/user_settings_live/show.ex:56 +msgid "A link to confirm your email change has been sent to the new address." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/show.ex:29 +msgid "Change the email address you want associated with your account." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/show.ex:26 +msgid "Update your email" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/notifiers/user_notifier.ex:9 +#: lib/claper_web/templates/user_notifier/magic.html.heex:10 +msgid "Connect to Claper" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/magic.html.heex:18 +msgid "ACCESS TO MY ACCOUNT" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/notifiers/user_notifier.ex:25 +msgid "Update email instructions" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/change.html.heex:18 +msgid "CONFIRM EMAIL" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/change.html.heex:10 +msgid "Confirm email" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/change.html.heex:20 +#: lib/claper_web/templates/user_notifier/magic.html.heex:20 +msgid "If you didn't create an account with us, please ignore this." +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/templates/user_notifier/magic.html.heex:14 +msgid "You can log into your account by clicking here." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:25 +#: lib/claper_web/live/event_live/post_component.ex:13 +#: lib/claper_web/live/event_live/post_component.ex:52 +msgid "Are you sure?" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:106 +msgid "Presentation attached" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:74 +msgid "Presentation uploaded" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:78 +#: lib/claper_web/live/event_live/form_component.html.heex:139 +#: lib/claper_web/live/event_live/form_component.html.heex:203 +#: lib/claper_web/live/event_live/form_component.html.heex:217 +msgid "Remove" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/form_component.html.heex:32 +#: lib/claper_web/live/event_live/form_component.html.heex:88 +msgid "Select your presentation" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:43 +msgid "Upload a file" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:47 +msgid "or drag and drop" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/form_component.ex:219 +msgid "You have selected an incorrect file type" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.ex:218 +msgid "Your file is too large" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/form_component.html.heex:111 +msgid "Change file" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/form_component.html.heex:133 +msgid "Presentation replaced" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/manage.html.heex:83 +msgid "Edit poll" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:82 +msgid "New poll" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/poll_live/form_component.html.heex:11 +msgid "Title of your poll" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/form_component.ex:220 +msgid "Upload failed" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:71 +msgid "Add poll to know opinion of your public." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:70 +#: lib/claper_web/live/event_live/manage.html.heex:123 +msgid "Poll" +msgstr "" + +#, elixir-format, ex-autogen +#: lib/claper_web/live/poll_live/form_component.html.heex:17 +msgid "Choice %{count}" +msgid_plural "Choice %{count}" +msgstr[0] "" +msgstr[1] "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/poll_component.ex:27 +msgid "Current poll" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/poll_component.ex:14 +msgid "See current poll" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/poll_component.ex:67 +#: lib/claper_web/live/event_live/poll_component.ex:69 +msgid "Vote" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/index.html.heex:49 +msgid "Invited presentations" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:199 +#: lib/claper_web/live/event_live/form_component.html.heex:210 +msgid "User email address" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:72 +msgid "Present/Customize" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:130 +msgid "Active" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:115 +msgid "Changing your file will remove all interaction elements like polls associated." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:171 +msgid "Messages from attendees will appear here." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:229 +msgid "On screen settings" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:105 +msgid "Processing your file..." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/poll_live/form_component.html.heex:48 +msgid "This will delete all responses associated and the poll itself, are you sure?" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/manage.html.heex:40 +msgid "Start" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/manage.html.heex:35 +msgid "Press F in the presentation window to enable fullscreen" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/show.html.heex:75 +msgid "Ask, comment..." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:243 +msgid "Active poll results" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:233 +msgid "Instructions" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/manage.html.heex:238 +#: lib/claper_web/live/stat_live/index.html.heex:41 +msgid "Messages" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:133 +msgid "Set active" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/form_component.html.heex:187 +msgid "Add facilitator" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/error/404.html.heex:32 +msgid "Oops, page doesn't exist." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/error/500.html.heex:32 +msgid "The site is under maintenance, we'll be back very soon!" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/form_component.html.heex:180 +msgid "Facilitators can present and manage interactions" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/change.html.heex:29 +#: lib/claper_web/templates/user_notifier/magic.html.heex:29 +msgid "If you’re having trouble with the button above, copy and paste the URL below into your web browser" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/templates/user_notifier/change.html.heex:14 +msgid "You can change your email by visiting the URL below" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/manage.html.heex:147 +msgid "Add interaction" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:181 +#: lib/claper_web/live/event_live/manage.html.heex:183 +msgid "Blocking this user will delete all his messages and he will not be able to join again, confirm ?" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/show.ex:50 +#: lib/claper_web/live/event_live/show.ex:188 +#: lib/claper_web/live/event_live/show.ex:203 +msgid "You have been banned from this event" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:181 +#: lib/claper_web/live/event_live/manage.html.heex:183 +msgid "Ban" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/templates/user_registration/confirm.html.heex:17 +msgid ", click on the provided link to connect (check your spam !)" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:17 +msgid "Export your current presentation to PDF from your favorite slide presentation software (PowerPoint, etc)" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:20 +msgid "Wait few minutes for your file to be processed" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:19 +msgid "Choose a name for your event, a code for your attendees to join and dates when your attendees could start interacting" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:22 +msgid "Click Start to open your presentation and move the window on the big screen" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:21 +msgid "Click on Present/Customize to add interaction on your slides" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:14 +msgid "Congrats! You've taken the first step to improving your presentations. Here are the next steps to create step up your presentations with Claper:" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:23 +msgid "Enjoy ! ✨" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:30 +msgid "If you have any questions, feel free to email us. We also offer live chat during business hours." +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/templates/user_registration/confirm.html.heex:19 +msgid "We sent you an email, click on the provided link to connect (check your spam !)" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:10 +msgid "Welcome !" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/templates/user_notifier/welcome.html.heex:18 +msgid "Click on the create button on your dashboard" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/notifiers/user_notifier.ex:17 +msgid "Next steps to boost your presentations" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/templates/user_notifier/welcome.html.heex:25 +msgid "To have more than 25 attendees and create more than one presentation by month, upgrade your plan." +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:45 +msgid "from %{count} people" +msgid_plural "from %{count} peoples" +msgstr[0] "" +msgstr[1] "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:62 +msgid "from %{count} poll" +msgid_plural "from %{count} polls" +msgstr[0] "" +msgstr[1] "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:58 +msgid "Average voters" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:14 +msgid "Event" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:86 +msgid "Interactions history" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:122 +msgid "No messages has been sent" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:119 +msgid "Report" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:124 +msgid "This will delete all data related to your event, this cannot be undone. Confirm ?" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:28 +msgid "attendee" +msgid_plural "attendees" +msgstr[0] "" +msgstr[1] "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:24 +msgid "Audience peak" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:75 +msgid "Engagement rate" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:91 +msgid "Error when processing the file" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:63 +msgid "Error when processing the new file" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/form_component.html.heex:49 +msgid "PDF, PPT, PPTX up to 15MB" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/join.html.heex:13 +#: lib/claper_web/live/event_live/join.html.heex:24 +msgid "About" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/join.html.heex:17 +#: lib/claper_web/live/event_live/join.html.heex:28 +msgid "Login" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_session/new.html.heex:25 +msgid "Connect to your account" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/errors.po b/priv/gettext/en/LC_MESSAGES/errors.po new file mode 100644 index 0000000..4abede5 --- /dev/null +++ b/priv/gettext/en/LC_MESSAGES/errors.po @@ -0,0 +1,97 @@ +## `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 "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +## 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 at least %{count} character(s)" +msgid_plural "should be at least %{count} character(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 most %{count} character(s)" +msgid_plural "should be at most %{count} character(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] "" + +## 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 "" diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot new file mode 100644 index 0000000..aeeadad --- /dev/null +++ b/priv/gettext/errors.pot @@ -0,0 +1,94 @@ +## 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 be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have %{count} item(s)" +msgid_plural "should have %{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 have at least %{count} item(s)" +msgid_plural "should have at least %{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 have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(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 "" diff --git a/priv/gettext/fr/LC_MESSAGES/default.po b/priv/gettext/fr/LC_MESSAGES/default.po new file mode 100644 index 0000000..8855bed --- /dev/null +++ b/priv/gettext/fr/LC_MESSAGES/default.po @@ -0,0 +1,741 @@ +## "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\n" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/show.ex:35 +#: lib/claper_web/live/user_settings_live/show.html.heex:5 +#: lib/claper_web/templates/layout/_user_menu.html.heex:2 +msgid "Settings" +msgstr "Paramètres" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/form_component.html.heex:7 +#: lib/claper_web/live/user_settings_live/show.html.heex:26 +#: lib/claper_web/templates/user_registration/new.html.heex:22 +#: lib/claper_web/templates/user_session/new.html.heex:34 +msgid "Email" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_registration/new.html.heex:13 +msgid "Join the Claper experience" +msgstr "Rejoignez l'expérience Claper" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_registration/new.html.heex:19 +msgid "Oops, check that all fields are filled in correctly." +msgstr "Oups, vérifiez que tous les champs sont remplis correctement." + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/show.html.heex:53 +msgid "Change" +msgstr "Changer" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:167 +msgid "Code" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/show.html.heex:48 +msgid "Email address" +msgstr "Adresse email" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/templates/layout/_user_menu.html.heex:5 +msgid "Logout" +msgstr "Déconnexion" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/show.html.heex:38 +msgid "Personal informations" +msgstr "Informations personnelles" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:28 +msgid "Upgrade" +msgstr "Mettre à niveau" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_registration/confirm.html.heex:14 +msgid "We already sent you an email to login, please retry in 5 minutes." +msgstr "Nous vous avons déjà envoyé un email pour vous connecter, veuillez réessayer dans 5 minutes." + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_registration/confirm.html.heex:17 +msgid "We sent you an email at" +msgstr "Nous vous avons envoyé un email à" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/show.html.heex:41 +msgid "Your personal informations only visible by you" +msgstr "Vos informations personnelles ne sont visibles que par vous" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/presenter.html.heex:15 +#: lib/claper_web/live/event_live/show.html.heex:127 +msgid "Or go to Claper.co and use the code:" +msgstr "Ou allez sur Claper.co et utilisez le code:" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/show.html.heex:114 +msgid "days" +msgstr "jours" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/show.html.heex:115 +msgid "hours" +msgstr "heures" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/show.html.heex:116 +msgid "minutes" +msgstr "minutes" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/show.html.heex:49 +msgid "Be the first to react !" +msgstr "Soyez le premier à réagir !" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:76 +#: lib/claper_web/live/event_live/join.ex:42 +#: lib/claper_web/live/event_live/join.html.heex:62 +msgid "Join" +msgstr "Rejoindre" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/index.ex:82 +#: lib/claper_web/live/event_live/join.html.heex:15 +#: lib/claper_web/live/event_live/join.html.heex:26 +msgid "Dashboard" +msgstr "Tableau de bord" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/post_component.ex:43 +msgid "Host" +msgstr "Animateur" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_session/new.html.heex:38 +msgid "Send link by email" +msgstr "Envoyer le lien par email" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/show.html.heex:117 +msgid "seconds" +msgstr "secondes" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/index.html.heex:39 +msgid "Create your first presentation" +msgstr "Créez votre première présentation" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_session/new.html.heex:34 +msgid "Enter your address email..." +msgstr "Entrez votre adresse email..." + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:46 +#: lib/claper_web/live/event_live/manage.html.heex:29 +msgid "Finish on" +msgstr "Termine le" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:30 +msgid "Finished" +msgstr "Terminé" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:56 +msgid "Finished on" +msgstr "Terminé le" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:20 +msgid "In progress" +msgstr "En cours" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:25 +msgid "Incoming" +msgstr "À venir" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/show.html.heex:16 +msgid "Leave" +msgstr "Quitter l'événement" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/index.html.heex:10 +msgid "My presentations" +msgstr "Mes présentations" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/form_component.html.heex:163 +msgid "Name of your presentation" +msgstr "Nom de votre présentation" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/presenter.html.heex:12 +#: lib/claper_web/live/event_live/show.html.heex:124 +msgid "Scan to interact in real-time" +msgstr "Scannez pour interagir en temps réel" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:51 +msgid "Starting on" +msgstr "Commence le" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.ex:210 +msgid "Updated successfully" +msgstr "Mis à jour avec succès" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:171 +msgid "When your presentation will be available ?" +msgstr "Quand votre présentation sera-t-elle accessible ?" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/join.html.heex:44 +msgid "Return to your last presentation" +msgstr "Revenir à votre dernier événement" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/templates/user_session/new.html.heex:23 +msgid "It's time to empower your presentations." +msgstr "C'est le moment de propulser vos présentations." + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/error/404.html.heex:36 +#: lib/claper_web/templates/error/500.html.heex:37 +#: lib/claper_web/templates/user_registration/confirm.html.heex:25 +msgid "Return to home" +msgstr "Retourner à l'accueil" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/form_component.ex:182 +msgid "Created successfully" +msgstr "Mis à jour avec succès" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.ex:22 +#: lib/claper_web/live/event_live/presenter.ex:21 +#: lib/claper_web/live/event_live/show.ex:24 +msgid "Presentation doesn't exist" +msgstr "La présentation n'existe pas" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:82 +#: lib/claper_web/live/event_live/event_card_component.ex:95 +#: lib/claper_web/live/event_live/index.ex:65 +msgid "Edit" +msgstr "Modifier" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:13 +#: lib/claper_web/live/event_live/form_component.html.heex:20 +#: lib/claper_web/live/event_live/index.ex:71 +#: lib/claper_web/live/event_live/index.html.heex:23 +#: lib/claper_web/live/poll_live/form_component.html.heex:44 +msgid "Create" +msgstr "Créer" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:124 +#: lib/claper_web/live/event_live/form_component.html.heex:25 +#: lib/claper_web/live/event_live/manage.html.heex:185 +#: lib/claper_web/live/event_live/post_component.ex:13 +#: lib/claper_web/live/event_live/post_component.ex:52 +#: lib/claper_web/live/poll_live/form_component.html.heex:48 +msgid "Delete" +msgstr "Supprimer" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:12 +#: lib/claper_web/live/event_live/form_component.html.heex:19 +#: lib/claper_web/live/poll_live/form_component.html.heex:45 +#: lib/claper_web/live/user_settings_live/form_component.html.heex:9 +#: lib/claper_web/live/user_settings_live/show.html.heex:28 +msgid "Save" +msgstr "Sauvegarder" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/form_component.ex:34 +#: lib/claper_web/live/user_settings_live/show.ex:56 +msgid "A link to confirm your email change has been sent to the new address." +msgstr "Un lien pour confirmer votre changement d'email a été envoyé à la nouvelle adresse." + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/show.ex:29 +msgid "Change the email address you want associated with your account." +msgstr "Modifiez l'email que vous souhaitez associer à votre compte." + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/user_settings_live/show.ex:26 +msgid "Update your email" +msgstr "Changer votre email" + +#, elixir-autogen, elixir-format +#: lib/claper_web/notifiers/user_notifier.ex:9 +#: lib/claper_web/templates/user_notifier/magic.html.heex:10 +msgid "Connect to Claper" +msgstr "Se connecter à Claper" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/magic.html.heex:18 +msgid "ACCESS TO MY ACCOUNT" +msgstr "ACCÉDER À MON COMPTE" + +#, elixir-autogen, elixir-format +#: lib/claper_web/notifiers/user_notifier.ex:25 +msgid "Update email instructions" +msgstr "Instructions de modification d'email" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/change.html.heex:18 +msgid "CONFIRM EMAIL" +msgstr "CONFIRMER L'EMAIL" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/change.html.heex:10 +msgid "Confirm email" +msgstr "Confirmer l'email" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/change.html.heex:20 +#: lib/claper_web/templates/user_notifier/magic.html.heex:20 +msgid "If you didn't create an account with us, please ignore this." +msgstr "Si vous n'avez pas créé de compte chez nous, veuillez ignorer ceci." + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/templates/user_notifier/magic.html.heex:14 +msgid "You can log into your account by clicking here." +msgstr "Vous pouvez vous connecter à votre compte en cliquant ici." + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:25 +#: lib/claper_web/live/event_live/post_component.ex:13 +#: lib/claper_web/live/event_live/post_component.ex:52 +msgid "Are you sure?" +msgstr "Êtes-vous sûr?" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:106 +msgid "Presentation attached" +msgstr "Présentation jointe" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:74 +msgid "Presentation uploaded" +msgstr "Présentation chargée" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:78 +#: lib/claper_web/live/event_live/form_component.html.heex:139 +#: lib/claper_web/live/event_live/form_component.html.heex:203 +#: lib/claper_web/live/event_live/form_component.html.heex:217 +msgid "Remove" +msgstr "Supprimer" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/form_component.html.heex:32 +#: lib/claper_web/live/event_live/form_component.html.heex:88 +msgid "Select your presentation" +msgstr "Sélectionnez votre présentation" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:43 +msgid "Upload a file" +msgstr "Chargez un fichier" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:47 +msgid "or drag and drop" +msgstr "ou glisser-déposer" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/form_component.ex:219 +msgid "You have selected an incorrect file type" +msgstr "Vous avez sélectionné un type de fichier incorrect" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.ex:218 +msgid "Your file is too large" +msgstr "Votre fichier est trop volumineux" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/form_component.html.heex:111 +msgid "Change file" +msgstr "Changer le fichier" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/form_component.html.heex:133 +msgid "Presentation replaced" +msgstr "Présentation remplacée" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/manage.html.heex:83 +msgid "Edit poll" +msgstr "Modifier le sondage" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:82 +msgid "New poll" +msgstr "Nouveau sondage" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/poll_live/form_component.html.heex:11 +msgid "Title of your poll" +msgstr "Titre de votre sondage" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/form_component.ex:220 +msgid "Upload failed" +msgstr "Échec du chargement" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:71 +msgid "Add poll to know opinion of your public." +msgstr "Ajoutez un sondage pour connaître l'opinion de votre public." + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:70 +#: lib/claper_web/live/event_live/manage.html.heex:123 +msgid "Poll" +msgstr "Sondage" + +#, elixir-format, ex-autogen +#: lib/claper_web/live/poll_live/form_component.html.heex:17 +msgid "Choice %{count}" +msgid_plural "Choice %{count}" +msgstr[0] "Choix %{count}" +msgstr[1] "Choix %{count}" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/poll_component.ex:27 +msgid "Current poll" +msgstr "Sondage actuel" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/poll_component.ex:14 +msgid "See current poll" +msgstr "Voir le sondage" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/poll_component.ex:67 +#: lib/claper_web/live/event_live/poll_component.ex:69 +msgid "Vote" +msgstr "Voter" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/index.html.heex:49 +msgid "Invited presentations" +msgstr "Présentations invitées" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:199 +#: lib/claper_web/live/event_live/form_component.html.heex:210 +msgid "User email address" +msgstr "Adresse email" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:72 +msgid "Present/Customize" +msgstr "Présenter/Personnaliser" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:130 +msgid "Active" +msgstr "Actif" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/form_component.html.heex:115 +msgid "Changing your file will remove all interaction elements like polls associated." +msgstr "La modification de votre fichier supprimera tous les éléments d'interaction comme les sondages associés." + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:171 +msgid "Messages from attendees will appear here." +msgstr "Les messages des participants apparaîtront ici." + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:229 +msgid "On screen settings" +msgstr "Paramètres à l'écran" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:105 +msgid "Processing your file..." +msgstr "Traitement de votre fichier..." + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/poll_live/form_component.html.heex:48 +msgid "This will delete all responses associated and the poll itself, are you sure?" +msgstr "Cela supprimera toutes les réponses associées et le sondage lui-même, êtes-vous sûr ?" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/manage.html.heex:40 +msgid "Start" +msgstr "Démarrer" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/manage.html.heex:35 +msgid "Press F in the presentation window to enable fullscreen" +msgstr "Appuyez sur F dans la fenêtre de présentation pour activer le plein écran" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/show.html.heex:75 +msgid "Ask, comment..." +msgstr "Questionnez, commentez..." + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:243 +msgid "Active poll results" +msgstr "Résultats du sondage actif" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:233 +msgid "Instructions" +msgstr "" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/manage.html.heex:238 +#: lib/claper_web/live/stat_live/index.html.heex:41 +msgid "Messages" +msgstr "" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:133 +msgid "Set active" +msgstr "Activer" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/form_component.html.heex:187 +msgid "Add facilitator" +msgstr "Ajouter un animateur" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/error/404.html.heex:32 +msgid "Oops, page doesn't exist." +msgstr "Oups, la page n'existe pas." + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/error/500.html.heex:32 +msgid "The site is under maintenance, we'll be back very soon!" +msgstr "Le site est en cours de maintenance, nous serons de retour très bientôt !" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/form_component.html.heex:180 +msgid "Facilitators can present and manage interactions" +msgstr "Les animateurs peuvent présenter et gérer les interactions" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/change.html.heex:29 +#: lib/claper_web/templates/user_notifier/magic.html.heex:29 +msgid "If you’re having trouble with the button above, copy and paste the URL below into your web browser" +msgstr "Si vous rencontrez des difficultés avec le bouton ci-dessus, copiez et collez l'URL ci-dessous dans votre navigateur web" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/templates/user_notifier/change.html.heex:14 +msgid "You can change your email by visiting the URL below" +msgstr "Vous pouvez modifier votre email en visitant l'URL ci-dessous" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/manage.html.heex:147 +msgid "Add interaction" +msgstr "Ajouter une interaction" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:181 +#: lib/claper_web/live/event_live/manage.html.heex:183 +msgid "Blocking this user will delete all his messages and he will not be able to join again, confirm ?" +msgstr "Bloquer cet utilisateur supprimera tous ses messages et il ne pourra pas rejoindre à nouveau, confirmer ?" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/show.ex:50 +#: lib/claper_web/live/event_live/show.ex:188 +#: lib/claper_web/live/event_live/show.ex:203 +msgid "You have been banned from this event" +msgstr "Vous avez été banni de cet événement" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/manage.html.heex:181 +#: lib/claper_web/live/event_live/manage.html.heex:183 +msgid "Ban" +msgstr "Bannir" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/templates/user_registration/confirm.html.heex:17 +msgid ", click on the provided link to connect (check your spam !)" +msgstr ", cliquez sur le lien fourni pour vous connecter (vérifiez vos spams !)" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:17 +msgid "Export your current presentation to PDF from your favorite slide presentation software (PowerPoint, etc)" +msgstr "Exporter votre présentation actuelle au format PDF à partir de votre logiciel de présentation préféré (PowerPoint, etc)" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:20 +msgid "Wait few minutes for your file to be processed" +msgstr "Attendez quelques minutes pour que votre fichier soit traité" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:19 +msgid "Choose a name for your event, a code for your attendees to join and dates when your attendees could start interacting" +msgstr "Choisissez un nom pour votre événement, un code pour que vos participants puissent s'y joindre et des dates auxquelles vos participants pourraient commencer à interagir" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:22 +msgid "Click Start to open your presentation and move the window on the big screen" +msgstr "Cliquez sur Démarrer pour ouvrir votre présentation et déplacer la fenêtre sur le grand écran" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:21 +msgid "Click on Present/Customize to add interaction on your slides" +msgstr "Cliquez sur Présenter/Personnaliser pour ajouter une interaction sur vos slides" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:14 +msgid "Congrats! You've taken the first step to improving your presentations. Here are the next steps to create step up your presentations with Claper:" +msgstr "Félicitations! Vous avez fait le premier pas pour améliorer vos présentations. Voici les prochaines étapes pour créer des présentations plus performantes avec Claper:" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:23 +msgid "Enjoy ! ✨" +msgstr "Profitez ! ✨" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:30 +msgid "If you have any questions, feel free to email us. We also offer live chat during business hours." +msgstr "Si vous avez des questions, n'hésitez pas à nous contacter. Nous proposons également un service de chat en direct pendant les heures de bureau." + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/templates/user_registration/confirm.html.heex:19 +msgid "We sent you an email, click on the provided link to connect (check your spam !)" +msgstr "Nous vous avons envoyé un email, cliquez sur le lien fourni pour vous connecter." + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_notifier/welcome.html.heex:10 +msgid "Welcome !" +msgstr "Bienvenue !" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/templates/user_notifier/welcome.html.heex:18 +msgid "Click on the create button on your dashboard" +msgstr "Cliquez sur le bouton créer sur votre tableau de bord" + +#, elixir-autogen, elixir-format +#: lib/claper_web/notifiers/user_notifier.ex:17 +msgid "Next steps to boost your presentations" +msgstr "Les prochaines étapes pour booster vos présentations" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/templates/user_notifier/welcome.html.heex:25 +msgid "To have more than 25 attendees and create more than one presentation by month, upgrade your plan." +msgstr "Pour avoir plus de 25 participants et créer plus d'une présentation par mois, mettez votre plan à niveau." + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:45 +msgid "from %{count} people" +msgid_plural "from %{count} peoples" +msgstr[0] "de %{count} personne" +msgstr[1] "de %{count} personnes" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:62 +msgid "from %{count} poll" +msgid_plural "from %{count} polls" +msgstr[0] "de %{count} sondage" +msgstr[1] "de %{count} sondages" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:58 +msgid "Average voters" +msgstr "Votants moyens" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:14 +msgid "Event" +msgstr "Événement" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:86 +msgid "Interactions history" +msgstr "Historique des interactions" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:122 +msgid "No messages has been sent" +msgstr "Aucun message n'a été envoyé" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:119 +msgid "Report" +msgstr "Rapport" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:124 +msgid "This will delete all data related to your event, this cannot be undone. Confirm ?" +msgstr "Cette opération supprimera toutes les données relatives à votre événement, elle ne peut être annulée. Confirmer ?" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:28 +msgid "attendee" +msgid_plural "attendees" +msgstr[0] "participant" +msgstr[1] "participants" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:24 +msgid "Audience peak" +msgstr "Pic d'audience" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/stat_live/index.html.heex:75 +msgid "Engagement rate" +msgstr "Taux d'engagement" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:91 +msgid "Error when processing the file" +msgstr "Erreur lors du traitement du fichier" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/event_card_component.ex:63 +msgid "Error when processing the new file" +msgstr "Erreur lors du traitement du nouveau fichier" + +#, elixir-autogen, elixir-format, fuzzy +#: lib/claper_web/live/event_live/form_component.html.heex:49 +msgid "PDF, PPT, PPTX up to 15MB" +msgstr "PDF, PPT, PPTX jusqu'à 15MB" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/join.html.heex:13 +#: lib/claper_web/live/event_live/join.html.heex:24 +msgid "About" +msgstr "A propos" + +#, elixir-autogen, elixir-format +#: lib/claper_web/live/event_live/join.html.heex:17 +#: lib/claper_web/live/event_live/join.html.heex:28 +msgid "Login" +msgstr "Connexion" + +#, elixir-autogen, elixir-format +#: lib/claper_web/templates/user_session/new.html.heex:25 +msgid "Connect to your account" +msgstr "Connectez-vous à votre compte" diff --git a/priv/gettext/fr/LC_MESSAGES/errors.po b/priv/gettext/fr/LC_MESSAGES/errors.po new file mode 100644 index 0000000..4cbef12 --- /dev/null +++ b/priv/gettext/fr/LC_MESSAGES/errors.po @@ -0,0 +1,97 @@ +## `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 "ne peut être vide" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "a déjà été pris" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "est invalide" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "doit être accepté" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "a un format invalide" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "a une entrée invalide" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "est réservé" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "est toujours associé à cette entrée" + +msgid "are still associated with this entry" +msgstr "sont toujours associés à cette entrée" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "devrait être de %{count} caractère(s)" +msgstr[1] "devrait être de %{count} caractère(s)" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "devrait avoir %{count} élément(s)" +msgstr[1] "devrait avoir %{count} élément(s)" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "doit comporter au moins %{count} caractère(s)" +msgstr[1] "doit comporter au moins %{count} caractère(s)" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "devrait avoir au moins %{count} élément(s)" +msgstr[1] "devrait avoir au moins %{count} élément(s)" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "doit comporter au maximum %{count} caractère(s)" +msgstr[1] "doit comporter au maximum %{count} caractère(s)" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "devrait avoir au plus %{count} élément(s)" +msgstr[1] "devrait avoir au plus %{count} élément(s)" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "doit être inférieur à %{number}" + +msgid "must be greater than %{number}" +msgstr "doit être supérieur à %{number}" + +msgid "must be less than or equal to %{number}" +msgstr "doit être inférieur ou égal à %{number}" + +msgid "must be greater than or equal to %{number}" +msgstr "doit être supérieure ou égale à %{number}." + +msgid "must be equal to %{number}" +msgstr "doit être égal à %{number}" diff --git a/priv/repo/migrations/.formatter.exs b/priv/repo/migrations/.formatter.exs new file mode 100644 index 0000000..49f9151 --- /dev/null +++ b/priv/repo/migrations/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:ecto_sql], + inputs: ["*.exs"] +] diff --git a/priv/repo/migrations/20211007130631_create_users_auth_tables.exs b/priv/repo/migrations/20211007130631_create_users_auth_tables.exs new file mode 100644 index 0000000..2deeadf --- /dev/null +++ b/priv/repo/migrations/20211007130631_create_users_auth_tables.exs @@ -0,0 +1,32 @@ +defmodule Claper.Repo.Migrations.CreateUsersAuthTables do + use Ecto.Migration + + def change do + execute "CREATE EXTENSION IF NOT EXISTS citext", "" + execute "CREATE EXTENSION IF NOT EXISTS pgcrypto", "" + + create table(:users) do + add :uuid, :binary_id, null: false, default: fragment("gen_random_uuid()") + add :email, :citext, null: false + add :is_admin, :boolean, null: false, default: false, null: false + add :confirmed_at, :naive_datetime + timestamps() + end + + create unique_index(:users, [:email]) + create index(:users, [:uuid]) + + create table(:users_tokens) do + add :uuid, :binary_id, null: false, default: fragment("gen_random_uuid()") + add :user_id, references(:users, on_delete: :delete_all) + add :token, :binary, null: false + add :context, :string, null: false + add :sent_to, :string + timestamps(updated_at: false) + end + + create index(:users_tokens, [:user_id]) + create index(:users_tokens, [:uuid]) + create unique_index(:users_tokens, [:context, :token]) + end +end diff --git a/priv/repo/migrations/20211007145520_create_events.exs b/priv/repo/migrations/20211007145520_create_events.exs new file mode 100644 index 0000000..14b5bc9 --- /dev/null +++ b/priv/repo/migrations/20211007145520_create_events.exs @@ -0,0 +1,19 @@ +defmodule Claper.Repo.Migrations.CreateEvents do + use Ecto.Migration + + def change do + create table(:events) do + add :uuid, :binary_id, null: false, default: fragment("gen_random_uuid()") + add :name, :string + add :code, :string, null: false + add :user_id, references(:users, on_delete: :delete_all) + add :started_at, :naive_datetime, null: false + add :expired_at, :naive_datetime + timestamps() + end + + create unique_index(:events, [:id]) + create unique_index(:events, [:uuid]) + create index(:events, [:user_id]) + end +end diff --git a/priv/repo/migrations/20211007152922_create_posts.exs b/priv/repo/migrations/20211007152922_create_posts.exs new file mode 100644 index 0000000..b8a1499 --- /dev/null +++ b/priv/repo/migrations/20211007152922_create_posts.exs @@ -0,0 +1,25 @@ +defmodule Claper.Repo.Migrations.CreatePosts do + use Ecto.Migration + + def change do + create table(:posts) do + add :uuid, :binary_id, null: false, default: fragment("gen_random_uuid()") + add :body, :string, null: false + add :like_count, :integer, default: 0 + add :love_count, :integer, default: 0 + add :lol_count, :integer, default: 0 + add :position, :integer, default: 0 + add :name, :string + add :attendee_identifier, :string + add :event_id, references(:events, on_delete: :delete_all) + add :user_id, references(:users, on_delete: :delete_all) + + timestamps() + end + + create unique_index(:posts, [:uuid]) + create unique_index(:posts, [:id]) + create index(:posts, [:attendee_identifier]) + create index(:posts, [:user_id]) + end +end diff --git a/priv/repo/migrations/20220111171051_create_reactions.exs b/priv/repo/migrations/20220111171051_create_reactions.exs new file mode 100644 index 0000000..813e33d --- /dev/null +++ b/priv/repo/migrations/20220111171051_create_reactions.exs @@ -0,0 +1,17 @@ +defmodule Claper.Repo.Migrations.CreateReactions do + use Ecto.Migration + + def change do + create table(:reactions) do + add :icon, :string + add :attendee_identifier, :string + add :post_id, references(:posts, on_delete: :delete_all) + add :user_id, references(:users, on_delete: :delete_all) + + timestamps() + end + + create unique_index(:reactions, [:icon, :post_id, :user_id]) + create unique_index(:reactions, [:icon, :post_id, :attendee_identifier]) + end +end diff --git a/priv/repo/migrations/20220226210445_create_presentation_files.exs b/priv/repo/migrations/20220226210445_create_presentation_files.exs new file mode 100644 index 0000000..eb25067 --- /dev/null +++ b/priv/repo/migrations/20220226210445_create_presentation_files.exs @@ -0,0 +1,16 @@ +defmodule Claper.Repo.Migrations.CreatePresentationFiles do + use Ecto.Migration + + def change do + create table(:presentation_files) do + add :hash, :string + add :length, :integer + add :status, :string, default: "processing" + add :event_id, references(:events, on_delete: :delete_all) + + timestamps() + end + + create unique_index(:presentation_files, [:hash]) + end +end diff --git a/priv/repo/migrations/20220305222231_create_polls.exs b/priv/repo/migrations/20220305222231_create_polls.exs new file mode 100644 index 0000000..ab9f64f --- /dev/null +++ b/priv/repo/migrations/20220305222231_create_polls.exs @@ -0,0 +1,14 @@ +defmodule Claper.Repo.Migrations.CreatePolls do + use Ecto.Migration + + def change do + create table(:polls) do + add :title, :string, null: false + add :position, :integer, default: 0 + add :presentation_file_id, references(:presentation_files, on_delete: :nilify_all) + add :enabled, :boolean, default: true + + timestamps() + end + end +end diff --git a/priv/repo/migrations/20220305223506_create_poll_opts.exs b/priv/repo/migrations/20220305223506_create_poll_opts.exs new file mode 100644 index 0000000..e5bb75e --- /dev/null +++ b/priv/repo/migrations/20220305223506_create_poll_opts.exs @@ -0,0 +1,13 @@ +defmodule Claper.Repo.Migrations.CreatePollOpts do + use Ecto.Migration + + def change do + create table(:poll_opts) do + add :content, :string, null: false + add :vote_count, :integer, default: 0 + add :poll_id, references(:polls, on_delete: :delete_all) + + timestamps() + end + end +end diff --git a/priv/repo/migrations/20220314171347_create_activity_leaders.exs b/priv/repo/migrations/20220314171347_create_activity_leaders.exs new file mode 100644 index 0000000..dde3247 --- /dev/null +++ b/priv/repo/migrations/20220314171347_create_activity_leaders.exs @@ -0,0 +1,14 @@ +defmodule Claper.Repo.Migrations.CreateActivityLeaders do + use Ecto.Migration + + def change do + create table(:activity_leaders) do + add :event_id, references(:events, on_delete: :delete_all) + add :email, :string, null: false + + timestamps() + end + + create unique_index(:activity_leaders, [:event_id, :email]) + end +end diff --git a/priv/repo/migrations/20220409094249_create_presentation_states.exs b/priv/repo/migrations/20220409094249_create_presentation_states.exs new file mode 100644 index 0000000..999de73 --- /dev/null +++ b/priv/repo/migrations/20220409094249_create_presentation_states.exs @@ -0,0 +1,16 @@ +defmodule Claper.Repo.Migrations.CreatePresentationStates do + use Ecto.Migration + + def change do + create table(:presentation_states) do + add :presentation_file_id, references(:presentation_files, on_delete: :delete_all) + add :position, :integer, default: 0 + add :chat_visible, :boolean, default: false + add :poll_visible, :boolean, default: false + add :join_screen_visible, :boolean, default: false + add :banned, {:array, :string}, default: [] + + timestamps() + end + end +end diff --git a/priv/repo/migrations/20220418194055_create_poll_votes.exs b/priv/repo/migrations/20220418194055_create_poll_votes.exs new file mode 100644 index 0000000..87d2728 --- /dev/null +++ b/priv/repo/migrations/20220418194055_create_poll_votes.exs @@ -0,0 +1,17 @@ +defmodule Claper.Repo.Migrations.CreatePollVotes do + use Ecto.Migration + + def change do + create table(:poll_votes) do + add :attendee_identifier, :string + add :poll_id, references(:polls, on_delete: :delete_all) + add :poll_opt_id, references(:poll_opts, on_delete: :delete_all) + add :user_id, references(:users, on_delete: :delete_all) + + timestamps() + end + + create unique_index(:poll_votes, [:poll_id, :user_id]) + create unique_index(:poll_votes, [:poll_id, :attendee_identifier]) + end +end diff --git a/priv/repo/migrations/20220419141142_create_stats.exs b/priv/repo/migrations/20220419141142_create_stats.exs new file mode 100644 index 0000000..5b21cf4 --- /dev/null +++ b/priv/repo/migrations/20220419141142_create_stats.exs @@ -0,0 +1,12 @@ +defmodule Claper.Repo.Migrations.CreateStats do + use Ecto.Migration + + def change do + create table(:stats) do + add :event_id, references(:events, on_delete: :delete_all) + add :status, :string + + timestamps() + end + end +end diff --git a/priv/repo/migrations/20220420124141_events_add_audience_peak_column.exs b/priv/repo/migrations/20220420124141_events_add_audience_peak_column.exs new file mode 100644 index 0000000..bd77a9b --- /dev/null +++ b/priv/repo/migrations/20220420124141_events_add_audience_peak_column.exs @@ -0,0 +1,9 @@ +defmodule Claper.Repo.Migrations.PresentationFilesAddAudiencePeakColumn do + use Ecto.Migration + + def change do + alter table(:events) do + add :audience_peak, :integer, default: 1 + end + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs new file mode 100644 index 0000000..eda7201 --- /dev/null +++ b/priv/repo/seeds.exs @@ -0,0 +1,21 @@ +# Script for populating the database. You can run it as: +# +# mix run priv/repo/seeds.exs +# +# Inside the script, you can read and write to any of your +# repositories directly: +# +# Claper.Repo.insert!(%Claper.SomeSchema{}) +# +# We recommend using the bang functions (`insert!`, `update!` +# and so on) as they will fail if something goes wrong. + +user = + %Claper.Accounts.User{ + email: "admin@example.com", + confirmed_at: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second), + inserted_at: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second), + updated_at: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second), + is_admin: true + } + |> Claper.Repo.insert!() diff --git a/priv/static/favicon.ico b/priv/static/favicon.ico new file mode 100644 index 0000000..73de524 Binary files /dev/null and b/priv/static/favicon.ico differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-100.eot b/priv/static/fonts/Roboto/roboto-v29-latin-100.eot new file mode 100644 index 0000000..7741f6e Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-100.eot differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-100.svg b/priv/static/fonts/Roboto/roboto-v29-latin-100.svg new file mode 100644 index 0000000..e8c8fc8 --- /dev/null +++ b/priv/static/fonts/Roboto/roboto-v29-latin-100.svg @@ -0,0 +1,313 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-100.ttf b/priv/static/fonts/Roboto/roboto-v29-latin-100.ttf new file mode 100644 index 0000000..729bdd6 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-100.ttf differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-100.woff b/priv/static/fonts/Roboto/roboto-v29-latin-100.woff new file mode 100644 index 0000000..8983756 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-100.woff differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-100.woff2 b/priv/static/fonts/Roboto/roboto-v29-latin-100.woff2 new file mode 100644 index 0000000..187d90f Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-100.woff2 differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-100italic.eot b/priv/static/fonts/Roboto/roboto-v29-latin-100italic.eot new file mode 100644 index 0000000..74bbe5a Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-100italic.eot differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-100italic.svg b/priv/static/fonts/Roboto/roboto-v29-latin-100italic.svg new file mode 100644 index 0000000..c51ce87 --- /dev/null +++ b/priv/static/fonts/Roboto/roboto-v29-latin-100italic.svg @@ -0,0 +1,332 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-100italic.ttf b/priv/static/fonts/Roboto/roboto-v29-latin-100italic.ttf new file mode 100644 index 0000000..8c6caf5 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-100italic.ttf differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-100italic.woff b/priv/static/fonts/Roboto/roboto-v29-latin-100italic.woff new file mode 100644 index 0000000..c298589 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-100italic.woff differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-100italic.woff2 b/priv/static/fonts/Roboto/roboto-v29-latin-100italic.woff2 new file mode 100644 index 0000000..b3912ac Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-100italic.woff2 differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-300.eot b/priv/static/fonts/Roboto/roboto-v29-latin-300.eot new file mode 100644 index 0000000..d37d5fa Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-300.eot differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-300.svg b/priv/static/fonts/Roboto/roboto-v29-latin-300.svg new file mode 100644 index 0000000..4ded944 --- /dev/null +++ b/priv/static/fonts/Roboto/roboto-v29-latin-300.svg @@ -0,0 +1,312 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-300.ttf b/priv/static/fonts/Roboto/roboto-v29-latin-300.ttf new file mode 100644 index 0000000..37ccbcc Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-300.ttf differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-300.woff b/priv/static/fonts/Roboto/roboto-v29-latin-300.woff new file mode 100644 index 0000000..5565042 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-300.woff differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-300.woff2 b/priv/static/fonts/Roboto/roboto-v29-latin-300.woff2 new file mode 100644 index 0000000..46445bf Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-300.woff2 differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-300italic.eot b/priv/static/fonts/Roboto/roboto-v29-latin-300italic.eot new file mode 100644 index 0000000..9f89c84 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-300italic.eot differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-300italic.svg b/priv/static/fonts/Roboto/roboto-v29-latin-300italic.svg new file mode 100644 index 0000000..758402b --- /dev/null +++ b/priv/static/fonts/Roboto/roboto-v29-latin-300italic.svg @@ -0,0 +1,329 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-300italic.ttf b/priv/static/fonts/Roboto/roboto-v29-latin-300italic.ttf new file mode 100644 index 0000000..9b8b739 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-300italic.ttf differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-300italic.woff b/priv/static/fonts/Roboto/roboto-v29-latin-300italic.woff new file mode 100644 index 0000000..6bf71d9 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-300italic.woff differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-300italic.woff2 b/priv/static/fonts/Roboto/roboto-v29-latin-300italic.woff2 new file mode 100644 index 0000000..7ddae77 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-300italic.woff2 differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-500.eot b/priv/static/fonts/Roboto/roboto-v29-latin-500.eot new file mode 100644 index 0000000..fb2a160 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-500.eot differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-500.svg b/priv/static/fonts/Roboto/roboto-v29-latin-500.svg new file mode 100644 index 0000000..67eecf4 --- /dev/null +++ b/priv/static/fonts/Roboto/roboto-v29-latin-500.svg @@ -0,0 +1,305 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-500.ttf b/priv/static/fonts/Roboto/roboto-v29-latin-500.ttf new file mode 100644 index 0000000..d154a80 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-500.ttf differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-500.woff b/priv/static/fonts/Roboto/roboto-v29-latin-500.woff new file mode 100644 index 0000000..c9eb5ca Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-500.woff differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-500.woff2 b/priv/static/fonts/Roboto/roboto-v29-latin-500.woff2 new file mode 100644 index 0000000..ce795fa Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-500.woff2 differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-500italic.eot b/priv/static/fonts/Roboto/roboto-v29-latin-500italic.eot new file mode 100644 index 0000000..e14fad8 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-500italic.eot differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-500italic.svg b/priv/static/fonts/Roboto/roboto-v29-latin-500italic.svg new file mode 100644 index 0000000..bed50dc --- /dev/null +++ b/priv/static/fonts/Roboto/roboto-v29-latin-500italic.svg @@ -0,0 +1,326 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-500italic.ttf b/priv/static/fonts/Roboto/roboto-v29-latin-500italic.ttf new file mode 100644 index 0000000..f642959 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-500italic.ttf differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-500italic.woff b/priv/static/fonts/Roboto/roboto-v29-latin-500italic.woff new file mode 100644 index 0000000..35d2715 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-500italic.woff differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-500italic.woff2 b/priv/static/fonts/Roboto/roboto-v29-latin-500italic.woff2 new file mode 100644 index 0000000..04f45a1 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-500italic.woff2 differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-700.eot b/priv/static/fonts/Roboto/roboto-v29-latin-700.eot new file mode 100644 index 0000000..3e2d316 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-700.eot differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-700.svg b/priv/static/fonts/Roboto/roboto-v29-latin-700.svg new file mode 100644 index 0000000..11db87d --- /dev/null +++ b/priv/static/fonts/Roboto/roboto-v29-latin-700.svg @@ -0,0 +1,309 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-700.ttf b/priv/static/fonts/Roboto/roboto-v29-latin-700.ttf new file mode 100644 index 0000000..489236c Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-700.ttf differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-700.woff b/priv/static/fonts/Roboto/roboto-v29-latin-700.woff new file mode 100644 index 0000000..a5d98fc Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-700.woff differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-700.woff2 b/priv/static/fonts/Roboto/roboto-v29-latin-700.woff2 new file mode 100644 index 0000000..01d05fa Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-700.woff2 differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-700italic.eot b/priv/static/fonts/Roboto/roboto-v29-latin-700italic.eot new file mode 100644 index 0000000..213bea8 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-700italic.eot differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-700italic.svg b/priv/static/fonts/Roboto/roboto-v29-latin-700italic.svg new file mode 100644 index 0000000..050bee0 --- /dev/null +++ b/priv/static/fonts/Roboto/roboto-v29-latin-700italic.svg @@ -0,0 +1,325 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-700italic.ttf b/priv/static/fonts/Roboto/roboto-v29-latin-700italic.ttf new file mode 100644 index 0000000..567ca6c Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-700italic.ttf differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-700italic.woff b/priv/static/fonts/Roboto/roboto-v29-latin-700italic.woff new file mode 100644 index 0000000..a449c44 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-700italic.woff differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-700italic.woff2 b/priv/static/fonts/Roboto/roboto-v29-latin-700italic.woff2 new file mode 100644 index 0000000..2554ab8 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-700italic.woff2 differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-900.eot b/priv/static/fonts/Roboto/roboto-v29-latin-900.eot new file mode 100644 index 0000000..da9a215 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-900.eot differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-900.svg b/priv/static/fonts/Roboto/roboto-v29-latin-900.svg new file mode 100644 index 0000000..9efdf4e --- /dev/null +++ b/priv/static/fonts/Roboto/roboto-v29-latin-900.svg @@ -0,0 +1,302 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-900.ttf b/priv/static/fonts/Roboto/roboto-v29-latin-900.ttf new file mode 100644 index 0000000..e762f3f Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-900.ttf differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-900.woff b/priv/static/fonts/Roboto/roboto-v29-latin-900.woff new file mode 100644 index 0000000..c3933ba Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-900.woff differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-900.woff2 b/priv/static/fonts/Roboto/roboto-v29-latin-900.woff2 new file mode 100644 index 0000000..06437ae Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-900.woff2 differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-900italic.eot b/priv/static/fonts/Roboto/roboto-v29-latin-900italic.eot new file mode 100644 index 0000000..d5d3576 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-900italic.eot differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-900italic.svg b/priv/static/fonts/Roboto/roboto-v29-latin-900italic.svg new file mode 100644 index 0000000..f8f5ab3 --- /dev/null +++ b/priv/static/fonts/Roboto/roboto-v29-latin-900italic.svg @@ -0,0 +1,324 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-900italic.ttf b/priv/static/fonts/Roboto/roboto-v29-latin-900italic.ttf new file mode 100644 index 0000000..fd994fd Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-900italic.ttf differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-900italic.woff b/priv/static/fonts/Roboto/roboto-v29-latin-900italic.woff new file mode 100644 index 0000000..40c71e9 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-900italic.woff differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-900italic.woff2 b/priv/static/fonts/Roboto/roboto-v29-latin-900italic.woff2 new file mode 100644 index 0000000..57776a1 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-900italic.woff2 differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-italic.eot b/priv/static/fonts/Roboto/roboto-v29-latin-italic.eot new file mode 100644 index 0000000..d1a5344 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-italic.eot differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-italic.svg b/priv/static/fonts/Roboto/roboto-v29-latin-italic.svg new file mode 100644 index 0000000..4d59797 --- /dev/null +++ b/priv/static/fonts/Roboto/roboto-v29-latin-italic.svg @@ -0,0 +1,323 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-italic.ttf b/priv/static/fonts/Roboto/roboto-v29-latin-italic.ttf new file mode 100644 index 0000000..8b35810 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-italic.ttf differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-italic.woff b/priv/static/fonts/Roboto/roboto-v29-latin-italic.woff new file mode 100644 index 0000000..c8bc602 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-italic.woff differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-italic.woff2 b/priv/static/fonts/Roboto/roboto-v29-latin-italic.woff2 new file mode 100644 index 0000000..05508b0 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-italic.woff2 differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-regular.eot b/priv/static/fonts/Roboto/roboto-v29-latin-regular.eot new file mode 100644 index 0000000..6dd5e3a Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-regular.eot differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-regular.svg b/priv/static/fonts/Roboto/roboto-v29-latin-regular.svg new file mode 100644 index 0000000..627f5a3 --- /dev/null +++ b/priv/static/fonts/Roboto/roboto-v29-latin-regular.svg @@ -0,0 +1,308 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-regular.ttf b/priv/static/fonts/Roboto/roboto-v29-latin-regular.ttf new file mode 100644 index 0000000..637971a Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-regular.ttf differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-regular.woff b/priv/static/fonts/Roboto/roboto-v29-latin-regular.woff new file mode 100644 index 0000000..86b3863 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-regular.woff differ diff --git a/priv/static/fonts/Roboto/roboto-v29-latin-regular.woff2 b/priv/static/fonts/Roboto/roboto-v29-latin-regular.woff2 new file mode 100644 index 0000000..ebe1795 Binary files /dev/null and b/priv/static/fonts/Roboto/roboto-v29-latin-regular.woff2 differ diff --git a/priv/static/fonts/Roboto/type.xml b/priv/static/fonts/Roboto/type.xml new file mode 100644 index 0000000..72c7a18 --- /dev/null +++ b/priv/static/fonts/Roboto/type.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/priv/static/images/base-slide.jpg b/priv/static/images/base-slide.jpg new file mode 100644 index 0000000..998d3ff Binary files /dev/null and b/priv/static/images/base-slide.jpg differ diff --git a/priv/static/images/client-login.jpg b/priv/static/images/client-login.jpg new file mode 100644 index 0000000..5314627 Binary files /dev/null and b/priv/static/images/client-login.jpg differ diff --git a/priv/static/images/emails/bg-white-rombo.png b/priv/static/images/emails/bg-white-rombo.png new file mode 100644 index 0000000..e4e6d26 Binary files /dev/null and b/priv/static/images/emails/bg-white-rombo.png differ diff --git a/priv/static/images/emails/change.png b/priv/static/images/emails/change.png new file mode 100644 index 0000000..cd1c5a8 Binary files /dev/null and b/priv/static/images/emails/change.png differ diff --git a/priv/static/images/emails/lock4.png b/priv/static/images/emails/lock4.png new file mode 100644 index 0000000..294c2fd Binary files /dev/null and b/priv/static/images/emails/lock4.png differ diff --git a/priv/static/images/favicon.png b/priv/static/images/favicon.png new file mode 100644 index 0000000..3ca72cf Binary files /dev/null and b/priv/static/images/favicon.png differ diff --git a/priv/static/images/icons/arrow-white.svg b/priv/static/images/icons/arrow-white.svg new file mode 100644 index 0000000..0f7523e --- /dev/null +++ b/priv/static/images/icons/arrow-white.svg @@ -0,0 +1,32 @@ + + + + + + + + + diff --git a/priv/static/images/icons/arrow.svg b/priv/static/images/icons/arrow.svg new file mode 100644 index 0000000..f68656b --- /dev/null +++ b/priv/static/images/icons/arrow.svg @@ -0,0 +1,32 @@ + + + + + + + + + diff --git a/priv/static/images/icons/calendar-clear-outline.svg b/priv/static/images/icons/calendar-clear-outline.svg new file mode 100644 index 0000000..58f9463 --- /dev/null +++ b/priv/static/images/icons/calendar-clear-outline.svg @@ -0,0 +1,5 @@ + + Calendar Clear + + + \ No newline at end of file diff --git a/priv/static/images/icons/chatbubble-ellipses-outline.svg b/priv/static/images/icons/chatbubble-ellipses-outline.svg new file mode 100644 index 0000000..aea8925 --- /dev/null +++ b/priv/static/images/icons/chatbubble-ellipses-outline.svg @@ -0,0 +1 @@ +Chatbubble Ellipses \ No newline at end of file diff --git a/priv/static/images/icons/clap.svg b/priv/static/images/icons/clap.svg new file mode 100644 index 0000000..127aefd --- /dev/null +++ b/priv/static/images/icons/clap.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/priv/static/images/icons/create-outline.svg b/priv/static/images/icons/create-outline.svg new file mode 100644 index 0000000..b286f7c --- /dev/null +++ b/priv/static/images/icons/create-outline.svg @@ -0,0 +1 @@ +Create \ No newline at end of file diff --git a/priv/static/images/icons/danger.png b/priv/static/images/icons/danger.png new file mode 100644 index 0000000..f526263 Binary files /dev/null and b/priv/static/images/icons/danger.png differ diff --git a/priv/static/images/icons/easel-outline.svg b/priv/static/images/icons/easel-outline.svg new file mode 100644 index 0000000..b452040 --- /dev/null +++ b/priv/static/images/icons/easel-outline.svg @@ -0,0 +1,5 @@ + + Easel + + + \ No newline at end of file diff --git a/priv/static/images/icons/easel.svg b/priv/static/images/icons/easel.svg new file mode 100644 index 0000000..6813241 --- /dev/null +++ b/priv/static/images/icons/easel.svg @@ -0,0 +1,5 @@ + + Easel + + + \ No newline at end of file diff --git a/priv/static/images/icons/ellipsis-horizontal-white.svg b/priv/static/images/icons/ellipsis-horizontal-white.svg new file mode 100644 index 0000000..db248ab --- /dev/null +++ b/priv/static/images/icons/ellipsis-horizontal-white.svg @@ -0,0 +1 @@ +Ellipsis Horizontal \ No newline at end of file diff --git a/priv/static/images/icons/ellipsis-horizontal.svg b/priv/static/images/icons/ellipsis-horizontal.svg new file mode 100644 index 0000000..dc879be --- /dev/null +++ b/priv/static/images/icons/ellipsis-horizontal.svg @@ -0,0 +1 @@ +Ellipsis Horizontal \ No newline at end of file diff --git a/priv/static/images/icons/email.png b/priv/static/images/icons/email.png new file mode 100644 index 0000000..fd2e231 Binary files /dev/null and b/priv/static/images/icons/email.png differ diff --git a/priv/static/images/icons/exit-outline.svg b/priv/static/images/icons/exit-outline.svg new file mode 100644 index 0000000..e0712b6 --- /dev/null +++ b/priv/static/images/icons/exit-outline.svg @@ -0,0 +1 @@ +Exit \ No newline at end of file diff --git a/priv/static/images/icons/eye-outline.svg b/priv/static/images/icons/eye-outline.svg new file mode 100644 index 0000000..438b932 --- /dev/null +++ b/priv/static/images/icons/eye-outline.svg @@ -0,0 +1,5 @@ + + Eye + + + \ No newline at end of file diff --git a/priv/static/images/icons/eye.svg b/priv/static/images/icons/eye.svg new file mode 100644 index 0000000..ec2e60d --- /dev/null +++ b/priv/static/images/icons/eye.svg @@ -0,0 +1,5 @@ + + Eye + + + \ No newline at end of file diff --git a/priv/static/images/icons/hashtag-white.svg b/priv/static/images/icons/hashtag-white.svg new file mode 100644 index 0000000..b56bd09 --- /dev/null +++ b/priv/static/images/icons/hashtag-white.svg @@ -0,0 +1,3 @@ + + + diff --git a/priv/static/images/icons/hashtag.svg b/priv/static/images/icons/hashtag.svg new file mode 100644 index 0000000..7959a5c --- /dev/null +++ b/priv/static/images/icons/hashtag.svg @@ -0,0 +1,3 @@ + + + diff --git a/priv/static/images/icons/heart.svg b/priv/static/images/icons/heart.svg new file mode 100644 index 0000000..d75ddb4 --- /dev/null +++ b/priv/static/images/icons/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/priv/static/images/icons/hundred.svg b/priv/static/images/icons/hundred.svg new file mode 100644 index 0000000..94799e1 --- /dev/null +++ b/priv/static/images/icons/hundred.svg @@ -0,0 +1,3 @@ + + + diff --git a/priv/static/images/icons/laugh.svg b/priv/static/images/icons/laugh.svg new file mode 100644 index 0000000..004b81f --- /dev/null +++ b/priv/static/images/icons/laugh.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/priv/static/images/icons/menu-outline.svg b/priv/static/images/icons/menu-outline.svg new file mode 100644 index 0000000..1a0134a --- /dev/null +++ b/priv/static/images/icons/menu-outline.svg @@ -0,0 +1 @@ +Menu \ No newline at end of file diff --git a/priv/static/images/icons/nft.svg b/priv/static/images/icons/nft.svg new file mode 100644 index 0000000..2ec2fd4 --- /dev/null +++ b/priv/static/images/icons/nft.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/priv/static/images/icons/online-users.svg b/priv/static/images/icons/online-users.svg new file mode 100644 index 0000000..3740ee4 --- /dev/null +++ b/priv/static/images/icons/online-users.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/priv/static/images/icons/raisehand.svg b/priv/static/images/icons/raisehand.svg new file mode 100644 index 0000000..ef7a85d --- /dev/null +++ b/priv/static/images/icons/raisehand.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/priv/static/images/icons/reader-outline.svg b/priv/static/images/icons/reader-outline.svg new file mode 100644 index 0000000..7200e5a --- /dev/null +++ b/priv/static/images/icons/reader-outline.svg @@ -0,0 +1 @@ +Reader \ No newline at end of file diff --git a/priv/static/images/icons/reload-outline.svg b/priv/static/images/icons/reload-outline.svg new file mode 100644 index 0000000..2244003 --- /dev/null +++ b/priv/static/images/icons/reload-outline.svg @@ -0,0 +1 @@ +Reload \ No newline at end of file diff --git a/priv/static/images/icons/send.svg b/priv/static/images/icons/send.svg new file mode 100644 index 0000000..76eedb7 --- /dev/null +++ b/priv/static/images/icons/send.svg @@ -0,0 +1 @@ +Send \ No newline at end of file diff --git a/priv/static/images/icons/star.svg b/priv/static/images/icons/star.svg new file mode 100644 index 0000000..cef6bac --- /dev/null +++ b/priv/static/images/icons/star.svg @@ -0,0 +1 @@ +Star \ No newline at end of file diff --git a/priv/static/images/icons/thumb.svg b/priv/static/images/icons/thumb.svg new file mode 100644 index 0000000..c19b8ac --- /dev/null +++ b/priv/static/images/icons/thumb.svg @@ -0,0 +1,4 @@ + + + + diff --git a/priv/static/images/icons/time-green.svg b/priv/static/images/icons/time-green.svg new file mode 100644 index 0000000..55ed94f --- /dev/null +++ b/priv/static/images/icons/time-green.svg @@ -0,0 +1 @@ +Time \ No newline at end of file diff --git a/priv/static/images/icons/time.svg b/priv/static/images/icons/time.svg new file mode 100644 index 0000000..3036a3a --- /dev/null +++ b/priv/static/images/icons/time.svg @@ -0,0 +1,4 @@ + + Time + + \ No newline at end of file diff --git a/priv/static/images/icons/user.svg b/priv/static/images/icons/user.svg new file mode 100644 index 0000000..6ec4d5b --- /dev/null +++ b/priv/static/images/icons/user.svg @@ -0,0 +1,3 @@ + + + diff --git a/priv/static/images/loading.gif b/priv/static/images/loading.gif new file mode 100644 index 0000000..19aaba8 Binary files /dev/null and b/priv/static/images/loading.gif differ diff --git a/priv/static/images/logo-large-black.svg b/priv/static/images/logo-large-black.svg new file mode 100644 index 0000000..f28dad5 --- /dev/null +++ b/priv/static/images/logo-large-black.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/static/images/logo-large.png b/priv/static/images/logo-large.png new file mode 100644 index 0000000..6296869 Binary files /dev/null and b/priv/static/images/logo-large.png differ diff --git a/priv/static/images/logo-large.svg b/priv/static/images/logo-large.svg new file mode 100644 index 0000000..e2d43af --- /dev/null +++ b/priv/static/images/logo-large.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/priv/static/images/logo-white.svg b/priv/static/images/logo-white.svg new file mode 100644 index 0000000..19682d7 --- /dev/null +++ b/priv/static/images/logo-white.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/priv/static/images/logo.png b/priv/static/images/logo.png new file mode 100644 index 0000000..b00656e Binary files /dev/null and b/priv/static/images/logo.png differ diff --git a/priv/static/images/logo.svg b/priv/static/images/logo.svg new file mode 100644 index 0000000..f12a370 --- /dev/null +++ b/priv/static/images/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/priv/static/images/mobile.png b/priv/static/images/mobile.png new file mode 100644 index 0000000..fd79918 Binary files /dev/null and b/priv/static/images/mobile.png differ diff --git a/priv/static/images/new-event-bg.png b/priv/static/images/new-event-bg.png new file mode 100644 index 0000000..b742d60 Binary files /dev/null and b/priv/static/images/new-event-bg.png differ diff --git a/priv/static/images/plans/free-plan.png b/priv/static/images/plans/free-plan.png new file mode 100644 index 0000000..8d2edf8 Binary files /dev/null and b/priv/static/images/plans/free-plan.png differ diff --git a/priv/static/images/plans/gold-plan.png b/priv/static/images/plans/gold-plan.png new file mode 100644 index 0000000..005e525 Binary files /dev/null and b/priv/static/images/plans/gold-plan.png differ diff --git a/priv/static/images/plans/platinum-plan.png b/priv/static/images/plans/platinum-plan.png new file mode 100644 index 0000000..8a9b934 Binary files /dev/null and b/priv/static/images/plans/platinum-plan.png differ diff --git a/priv/static/images/plans/silver-plan.png b/priv/static/images/plans/silver-plan.png new file mode 100644 index 0000000..347171b Binary files /dev/null and b/priv/static/images/plans/silver-plan.png differ diff --git a/priv/static/images/preview.png b/priv/static/images/preview.png new file mode 100644 index 0000000..8946578 Binary files /dev/null and b/priv/static/images/preview.png differ diff --git a/priv/static/robots.txt b/priv/static/robots.txt new file mode 100644 index 0000000..3c9c7c0 --- /dev/null +++ b/priv/static/robots.txt @@ -0,0 +1,5 @@ +# See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/rel/env.bat.eex b/rel/env.bat.eex new file mode 100644 index 0000000..60beb80 --- /dev/null +++ b/rel/env.bat.eex @@ -0,0 +1,5 @@ +@echo off +rem Set the release to work across nodes. +rem RELEASE_DISTRIBUTION must be "sname" (local), "name" (distributed) or "none". +rem set RELEASE_DISTRIBUTION=name +rem set RELEASE_NODE=<%= @release.name %> diff --git a/rel/env.sh.eex b/rel/env.sh.eex new file mode 100644 index 0000000..fab92fe --- /dev/null +++ b/rel/env.sh.eex @@ -0,0 +1,21 @@ +#!/bin/sh + +# Sets and enables heart (recommended only in daemon mode) +# case $RELEASE_COMMAND in +# daemon*) +# HEART_COMMAND="$RELEASE_ROOT/bin/$RELEASE_NAME $RELEASE_COMMAND" +# export HEART_COMMAND +# export ELIXIR_ERL_OPTIONS="-heart" +# ;; +# *) +# ;; +# esac + +# Set the release to work across nodes. +# RELEASE_DISTRIBUTION must be "sname" (local), "name" (distributed) or "none". +#export RELEASE_DISTRIBUTION=sname +#export RELEASE_NODE=<%= @release.name %> + +export POD_A_RECORD=$(echo $POD_IP | sed 's/\./-/g') +export RELEASE_DISTRIBUTION=name +export RELEASE_NODE=<%= @release.name %>@$(echo $POD_A_RECORD).$(echo $NAMESPACE).pod.cluster.local diff --git a/rel/remote.vm.args.eex b/rel/remote.vm.args.eex new file mode 100644 index 0000000..5886aa8 --- /dev/null +++ b/rel/remote.vm.args.eex @@ -0,0 +1,11 @@ +## Customize flags given to the VM: https://erlang.org/doc/man/erl.html +## -mode/-name/-sname/-setcookie are configured via env vars, do not set them here + +## Number of dirty schedulers doing IO work (file, sockets, and others) +##+SDio 5 + +## Increase number of concurrent ports/sockets +##+Q 65536 + +## Tweak GC to run more often +##-env ERL_FULLSWEEP_AFTER 10 diff --git a/rel/vm.args.eex b/rel/vm.args.eex new file mode 100644 index 0000000..5886aa8 --- /dev/null +++ b/rel/vm.args.eex @@ -0,0 +1,11 @@ +## Customize flags given to the VM: https://erlang.org/doc/man/erl.html +## -mode/-name/-sname/-setcookie are configured via env vars, do not set them here + +## Number of dirty schedulers doing IO work (file, sockets, and others) +##+SDio 5 + +## Increase number of concurrent ports/sockets +##+Q 65536 + +## Tweak GC to run more often +##-env ERL_FULLSWEEP_AFTER 10 diff --git a/reset-db.sh b/reset-db.sh new file mode 100755 index 0000000..e3370b6 --- /dev/null +++ b/reset-db.sh @@ -0,0 +1,6 @@ +docker stop claper-db +docker rm claper-db +docker run -p 5432:5432 -e POSTGRES_PASSWORD=claper -e POSTGRES_USER=claper -e POSTGRES_DB=claper --name claper-db -d postgres:9 +sleep 5 +mix ecto.migrate +mix run priv/repo/seeds.exs \ No newline at end of file diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..9f57dc1 --- /dev/null +++ b/start.sh @@ -0,0 +1,5 @@ +docker start claper-db + +export $(cat .env | xargs) + +mix phx.server \ No newline at end of file diff --git a/test/claper/accounts_test.exs b/test/claper/accounts_test.exs new file mode 100644 index 0000000..9e96d9e --- /dev/null +++ b/test/claper/accounts_test.exs @@ -0,0 +1,309 @@ +defmodule Claper.AccountsTest do + use Claper.DataCase + + alias Claper.Accounts + + import Claper.AccountsFixtures + alias Claper.Accounts.{User, UserToken} + + require Logger + + describe "get_user_by_email/1" do + test "does not return the user if the email does not exist" do + refute Accounts.get_user_by_email("unknown@example.com") + end + + test "returns the user if the email exists" do + %{id: id} = user = user_fixture() + assert %User{id: ^id} = Accounts.get_user_by_email(user.email) + end + end + + describe "magic_token_valid?/1" do + test "does not return true if the email is not valid" do + refute Accounts.magic_token_valid?("unknown@example.com") + end + + test "does return true if token valid" do + user = user_fixture() + + Accounts.deliver_magic_link(user.email, fn _url -> "URL" end) + + assert Accounts.magic_token_valid?(user.email) == true + end + end + + describe "deliver_magic_link/2" do + setup do + %{user: user_fixture()} + end + + test "sends magic link through notification", %{user: user} do + token = + extract_magic_token(fn url -> + Accounts.deliver_magic_link(user.email, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + + # No user_id, we only check the user when the link is clicked + refute user_token.user_id + + assert user_token.sent_to == user.email + assert user_token.context == "magic" + end + end + + describe "get_user!/1" do + test "raises if id is invalid" do + assert_raise Ecto.NoResultsError, fn -> + Accounts.get_user!(-1) + end + end + + test "returns the user with the given id" do + %{id: id} = user = user_fixture() + assert %User{id: ^id} = Accounts.get_user!(user.id) + end + end + + describe "change_user_registration/2" do + test "returns a changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_registration(%User{}) + assert changeset.required == [:email] + end + + test "allows fields to be set" do + email = unique_user_email() + + changeset = + Accounts.change_user_registration( + %User{}, + valid_user_attributes(email: email) + ) + + assert changeset.valid? + assert get_change(changeset, :email) == email + end + end + + describe "change_user_email/2" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{}) + assert changeset.required == [:email] + end + end + + describe "apply_user_email/3" do + setup do + %{user: user_fixture()} + end + + test "requires email to change", %{user: user} do + {:error, changeset} = Accounts.apply_user_email(user, %{}) + assert %{email: ["did not change"]} = errors_on(changeset) + end + + test "validates email", %{user: user} do + {:error, changeset} = + Accounts.apply_user_email(user, %{email: "not valid"}) + + assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset) + end + + test "validates maximum value for email for security", %{user: user} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.apply_user_email(user, %{email: too_long}) + + assert "should be at most 160 character(s)" in errors_on(changeset).email + end + + test "validates email uniqueness", %{user: user} do + %{email: email} = user_fixture() + + {:error, changeset} = + Accounts.apply_user_email(user, %{email: email}) + + assert "has already been taken" in errors_on(changeset).email + end + + test "applies the email without persisting it", %{user: user} do + email = unique_user_email() + {:ok, user} = Accounts.apply_user_email(user, %{email: email}) + assert user.email == email + assert Accounts.get_user!(user.id).email != email + end + end + + describe "deliver_update_email_instructions/3" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_update_email_instructions(user, "current@example.com", url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "change:current@example.com" + end + end + + describe "update_user_email/2" do + setup do + user = user_fixture() + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{user: user, token: token, email: email} + end + + test "updates the email with a valid token", %{user: user, token: token, email: email} do + assert Accounts.update_user_email(user, token) == :ok + changed_user = Repo.get!(User, user.id) + assert changed_user.email != user.email + assert changed_user.email == email + assert changed_user.confirmed_at + assert changed_user.confirmed_at != user.confirmed_at + refute Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email with invalid token", %{user: user} do + assert Accounts.update_user_email(user, "oops") == :error + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email if user email changed", %{user: user, token: token} do + assert Accounts.update_user_email(%{user | email: "current@example.com"}, token) == :error + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + assert Accounts.update_user_email(user, token) == :error + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "generate_user_session_token/1" do + setup do + %{user: user_fixture()} + end + + test "generates a token", %{user: user} do + token = Accounts.generate_user_session_token(user) + assert user_token = Repo.get_by(UserToken, token: token) + assert user_token.context == "session" + + # Creating the same token for another user should fail + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(%UserToken{ + token: user_token.token, + user_id: user_fixture().id, + context: "session" + }) + end + end + end + + describe "get_user_by_session_token/1" do + setup do + user = user_fixture() + token = Accounts.generate_user_session_token(user) + %{user: user, token: token} + end + + test "returns user by token", %{user: user, token: token} do + assert session_user = Accounts.get_user_by_session_token(token) + assert session_user.id == user.id + end + + test "does not return user for invalid token" do + refute Accounts.get_user_by_session_token("oops") + end + + test "does not return user for expired token", %{token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_user_by_session_token(token) + end + end + + describe "delete_session_token/1" do + test "deletes the token" do + user = user_fixture() + token = Accounts.generate_user_session_token(user) + assert Accounts.delete_session_token(token) == :ok + refute Accounts.get_user_by_session_token(token) + end + end + + describe "deliver_user_confirmation_instructions/2" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "confirm" + end + end + + describe "confirm_user/1" do + setup do + user = user_fixture() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + %{user: user, token: token} + end + + test "confirms the email with a valid token", %{user: user, token: token} do + assert {:ok, confirmed_user} = Accounts.confirm_user(token) + assert confirmed_user.confirmed_at + assert confirmed_user.confirmed_at != user.confirmed_at + assert Repo.get!(User, user.id).confirmed_at + refute Repo.get_by(UserToken, user_id: user.id) + end + + test "does not confirm with invalid token", %{user: user} do + assert Accounts.confirm_user("oops") == :error + refute Repo.get!(User, user.id).confirmed_at + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not confirm email if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + assert Accounts.confirm_user(token) == :error + refute Repo.get!(User, user.id).confirmed_at + assert Repo.get_by(UserToken, user_id: user.id) + end + end + +end diff --git a/test/claper/events_test.exs b/test/claper/events_test.exs new file mode 100644 index 0000000..3d1898c --- /dev/null +++ b/test/claper/events_test.exs @@ -0,0 +1,76 @@ +defmodule Claper.EventsTest do + use Claper.DataCase + + alias Claper.Events + import Claper.{EventsFixtures,AccountsFixtures} + + describe "events" do + + alias Claper.Events.Event + + @invalid_attrs %{name: nil, code: nil} + + test "list_events/1 returns all events of a user" do + event = event_fixture() + assert Events.list_events(event.user_id) == [event] + end + + test "list_events/1 doesn't returns events of other users" do + event = event_fixture() + + event2 = event_fixture() + + assert Events.list_events(event.user_id) == [event] + assert Events.list_events(event.user_id) != [event2] + end + + test "get_event!/2 returns the event with given id" do + event = event_fixture() + assert Events.get_event!(event.uuid) == event + end + + test "get_user_event!/3 with invalid user raises exception" do + event = event_fixture() + event2 = event_fixture() + assert_raise Ecto.NoResultsError, fn -> Events.get_user_event!(event.user_id, event2.uuid) == event end + end + + test "create_event/1 with valid data creates a event" do + user = user_fixture() + valid_attrs = %{name: "some name", code: "code", user_id: user.id, started_at: NaiveDateTime.utc_now, expired_at: NaiveDateTime.add(NaiveDateTime.utc_now, 7200, :second)} + + assert {:ok, %Event{} = event} = Events.create_event(valid_attrs) + assert event.name == "some name" + end + + test "create_event/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Events.create_event(@invalid_attrs) + end + + test "update_event/2 with valid data updates the event" do + event = event_fixture() + update_attrs = %{name: "some updated name"} + + assert {:ok, %Event{} = event} = Events.update_event(event, update_attrs) + assert event.name == "some updated name" + end + + test "update_event/2 with invalid data returns error changeset" do + event = event_fixture() + assert {:error, %Ecto.Changeset{}} = Events.update_event(event, @invalid_attrs) + assert event == Events.get_event!(event.uuid) + end + + test "delete_event/1 deletes the event" do + event = event_fixture() + + assert {:ok, %Event{}} = Events.delete_event(event) + assert_raise Ecto.NoResultsError, fn -> Events.get_event!(event.uuid) end + end + + test "change_event/1 returns a event changeset" do + event = event_fixture() + assert %Ecto.Changeset{} = Events.change_event(event) + end + end +end diff --git a/test/claper/polls_test.exs b/test/claper/polls_test.exs new file mode 100644 index 0000000..27a7398 --- /dev/null +++ b/test/claper/polls_test.exs @@ -0,0 +1,119 @@ +defmodule Claper.PollsTest do + use Claper.DataCase + + alias Claper.Polls + + describe "polls" do + alias Claper.Polls.Poll + + import Claper.{PollsFixtures,PresentationsFixtures} + + @invalid_attrs %{title: nil} + + test "list_polls/1 returns all polls from a presentation" do + presentation_file = presentation_file_fixture() + poll = poll_fixture(%{presentation_file_id: presentation_file.id }) + + assert Polls.list_polls(presentation_file.id) == [poll] + end + + test "list_polls_at_position/2 returns all polls from a presentation at a given position" do + presentation_file = presentation_file_fixture() + poll = poll_fixture(%{presentation_file_id: presentation_file.id, position: 5 }) + + assert Polls.list_polls_at_position(presentation_file.id, 5) == [poll] + end + + test "get_poll!/1 returns the poll with given id" do + presentation_file = presentation_file_fixture() + poll = poll_fixture(%{presentation_file_id: presentation_file.id }) |> Claper.Polls.set_percentages() + + assert Polls.get_poll!(poll.id) == poll + end + + test "create_poll/1 with valid data creates a poll" do + presentation_file = presentation_file_fixture() + valid_attrs = %{title: "some title", presentation_file_id: presentation_file.id, position: 0, poll_opts: [ + %{content: "some option 1", vote_count: 0}, + %{content: "some option 2", vote_count: 0}, + ]} + + assert {:ok, %Poll{} = poll} = Polls.create_poll(valid_attrs) + assert poll.title == "some title" + end + + test "create_poll/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Polls.create_poll(@invalid_attrs) + end + + test "update_poll/3 with valid data updates the poll" do + presentation_file = presentation_file_fixture() + poll = poll_fixture(%{presentation_file_id: presentation_file.id}) + update_attrs = %{title: "some updated title"} + + assert {:ok, %Poll{} = poll} = Polls.update_poll(presentation_file.event_id, poll, update_attrs) + assert poll.title == "some updated title" + end + + test "update_poll/3 with invalid data returns error changeset" do + presentation_file = presentation_file_fixture() + poll = poll_fixture(%{presentation_file_id: presentation_file.id}) + assert {:error, %Ecto.Changeset{}} = Polls.update_poll(presentation_file.event_id, poll, @invalid_attrs) + assert poll |> Claper.Polls.set_percentages() == Polls.get_poll!(poll.id) + end + + test "delete_poll/2 deletes the poll" do + presentation_file = presentation_file_fixture() + poll = poll_fixture(%{presentation_file_id: presentation_file.id}) + + assert {:ok, %Poll{}} = Polls.delete_poll(presentation_file.event_id, poll) + assert_raise Ecto.NoResultsError, fn -> Polls.get_poll!(poll.id) end + end + + test "change_poll/1 returns a poll changeset" do + presentation_file = presentation_file_fixture() + poll = poll_fixture(%{presentation_file_id: presentation_file.id}) + assert %Ecto.Changeset{} = Polls.change_poll(poll) + end + end + + describe "poll_opts" do + + import Claper.{PollsFixtures,PresentationsFixtures} + + test "add_poll_opt/1 returns poll changeset plus the added poll_opt" do + presentation_file = presentation_file_fixture() + poll = poll_fixture(%{presentation_file_id: presentation_file.id}) + poll_changeset = poll |> Polls.change_poll() + + assert Ecto.Changeset.get_field(Polls.add_poll_opt(poll_changeset), :poll_opts) |> Enum.count == 3 + end + + test "remove_poll_opt/2 returns poll changeset minus the removed poll_opt" do + presentation_file = presentation_file_fixture() + poll = poll_fixture(%{presentation_file_id: presentation_file.id}) + poll_changeset = poll |> Polls.change_poll() + + assert Ecto.Changeset.get_field(Polls.remove_poll_opt(poll_changeset, Enum.at(poll.poll_opts, 0)), :poll_opts) |> Enum.count == 1 + end + end + + describe "poll_votes" do + + import Claper.{PollsFixtures,PresentationsFixtures} + + test "get_poll_vote/2 returns the poll_vote with given id and user id" do + poll_vote = poll_vote_fixture() + assert Polls.get_poll_vote(poll_vote.user_id, poll_vote.poll_id) == poll_vote + end + + test "vote/4 with valid data creates a poll_vote" do + presentation_file = presentation_file_fixture(%{}, [:event]) + poll = poll_fixture(%{presentation_file_id: presentation_file.id}) + [poll_opt | _] = poll.poll_opts + + + assert {:ok, %Polls.Poll{}} = Polls.vote(presentation_file.event.user_id, presentation_file.event_id, poll_opt, poll.id) + end + end +end diff --git a/test/claper/posts_test.exs b/test/claper/posts_test.exs new file mode 100644 index 0000000..04a3602 --- /dev/null +++ b/test/claper/posts_test.exs @@ -0,0 +1,93 @@ +defmodule Claper.PostsTest do + use Claper.DataCase + + alias Claper.Posts + + import Claper.{PostsFixtures, AccountsFixtures, EventsFixtures} + + alias Claper.Posts.Post + + describe "posts" do + + @invalid_attrs %{body: "a"} + + test "list_posts/0 returns all posts from an event" do + post = post_fixture(%{}, [:event]) + assert Posts.list_posts(post.event.uuid, [:event]) == [post] + end + + test "get_post!/1 returns the post with given id" do + post = post_fixture(%{}, [:event]) + assert Posts.get_post!(post.uuid, [:event]) == post + end + + test "create_post/1 with valid data creates a post" do + user = user_fixture() + event = event_fixture() + valid_attrs = %{body: "some body", user_id: user.id, event_id: event.id} + + assert {:ok, %Post{} = post} = Posts.create_post(event, valid_attrs) + assert post.body == "some body" + end + + test "create_post/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Posts.create_post(%{}, @invalid_attrs) + end + + test "update_post/2 with valid data updates the post" do + post = post_fixture() + update_attrs = %{body: "some updated body"} + + assert {:ok, %Post{} = post} = Posts.update_post(post, update_attrs) + assert post.body == "some updated body" + end + + test "update_post/2 with invalid data returns error changeset" do + post = post_fixture(%{}, [:event]) + assert {:error, %Ecto.Changeset{}} = Posts.update_post(post, @invalid_attrs) + assert post == Posts.get_post!(post.uuid, [:event]) + end + + test "delete_post/1 deletes the post" do + post = post_fixture() + assert {:ok, %Post{}} = Posts.delete_post(post) + assert_raise Ecto.NoResultsError, fn -> Posts.get_post!(post.uuid) end + end + end + + + describe "reactions" do + alias Claper.Posts.Reaction + + import Claper.PostsFixtures + + @invalid_attrs %{icon: nil, post: nil} + + test "reacted_posts/3 from a post for a given user" do + post = post_fixture() + reaction = reaction_fixture(%{post: post, user_id: post.user_id}) + + assert Posts.reacted_posts(post.event_id, post.user_id, reaction.icon) == [post.id] + end + + test "create_reaction/1 with valid data creates a reaction" do + post = post_fixture() + valid_attrs = %{icon: "some icon", post: post, user_id: post.user_id} + + assert {:ok, %Reaction{} = reaction} = Posts.create_reaction(valid_attrs) + assert reaction.icon == "some icon" + end + + test "create_reaction/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Posts.create_reaction(@invalid_attrs) + end + + test "delete_reaction/1 deletes the reaction" do + post = post_fixture() + reaction = reaction_fixture(%{post: post, user_id: post.user_id}) + + assert {:ok, %Post{}} = Posts.delete_reaction(%{user_id: post.user_id, post: post, icon: "some icon"}) + assert_raise Ecto.NoResultsError, fn -> Posts.get_reaction!(reaction.id) end + end + end +end diff --git a/test/claper/presentations_test.exs b/test/claper/presentations_test.exs new file mode 100644 index 0000000..fbea48c --- /dev/null +++ b/test/claper/presentations_test.exs @@ -0,0 +1,57 @@ +defmodule Claper.PresentationsTest do + use Claper.DataCase + + alias Claper.Presentations + + describe "presentation_files" do + alias Claper.Presentations.PresentationFile + + import Claper.PresentationsFixtures + + test "get_presentation_file!/2 returns the presentation_file with given id" do + presentation_file = presentation_file_fixture() + assert Presentations.get_presentation_file!(presentation_file.id) == presentation_file + end + + test "get_presentation_file_by_hash!/2 returns the presentation_file with given hash" do + presentation_file = presentation_file_fixture() + assert Presentations.get_presentation_file_by_hash!(presentation_file.hash) == %{presentation_file | event: nil} + end + + test "create_presentation_file/1 with valid data creates a presentation_file" do + valid_attrs = %{hash: "1234", length: 42} + + assert {:ok, %PresentationFile{} = presentation_file} = Presentations.create_presentation_file(valid_attrs) + assert presentation_file.hash == "1234" + assert presentation_file.length == 42 + end + + test "update_presentation_file/2 with valid data updates the presentation_file" do + presentation_file = presentation_file_fixture() + update_attrs = %{hash: "4567", length: 43} + + assert {:ok, %PresentationFile{} = presentation_file} = Presentations.update_presentation_file(presentation_file, update_attrs) + assert presentation_file.hash == "4567" + assert presentation_file.length == 43 + end + end + + describe "presentation_states" do + alias Claper.Presentations.PresentationState + + import Claper.PresentationsFixtures + + test "create_presentation_state/1 with valid data creates a presentation_state" do + valid_attrs = %{} + + assert {:ok, %PresentationState{}} = Presentations.create_presentation_state(valid_attrs) + end + + test "update_presentation_state/2 with valid data updates the presentation_state" do + presentation_state = presentation_state_fixture() + update_attrs = %{} + + assert {:ok, %PresentationState{}} = Presentations.update_presentation_state(presentation_state, update_attrs) + end + end +end diff --git a/test/claper_web/controllers/page_controller_test.exs b/test/claper_web/controllers/page_controller_test.exs new file mode 100644 index 0000000..062d5dd --- /dev/null +++ b/test/claper_web/controllers/page_controller_test.exs @@ -0,0 +1,8 @@ +defmodule ClaperWeb.PageControllerTest do + use ClaperWeb.ConnCase + + test "GET /", %{conn: conn} do + conn = get(conn, "/") + assert html_response(conn, 200) =~ "Welcome to Claper!" + end +end diff --git a/test/claper_web/controllers/user_auth_test.exs b/test/claper_web/controllers/user_auth_test.exs new file mode 100644 index 0000000..a6c2c44 --- /dev/null +++ b/test/claper_web/controllers/user_auth_test.exs @@ -0,0 +1,170 @@ +defmodule ClaperWeb.UserAuthTest do + use ClaperWeb.ConnCase, async: true + + alias Claper.Accounts + alias ClaperWeb.UserAuth + import Claper.AccountsFixtures + + @remember_me_cookie "_claper_web_user_remember_me" + + setup %{conn: conn} do + conn = + conn + |> Map.replace!(:secret_key_base, ClaperWeb.Endpoint.config(:secret_key_base)) + |> init_test_session(%{}) + + %{user: user_fixture(), conn: conn} + end + + describe "log_in_user/3" do + test "stores the user token in the session", %{conn: conn, user: user} do + conn = UserAuth.log_in_user(conn, user) + assert token = get_session(conn, :user_token) + assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}" + assert redirected_to(conn) == "/" + assert Accounts.get_user_by_session_token(token) + end + + test "clears everything previously stored in the session", %{conn: conn, user: user} do + conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user) + refute get_session(conn, :to_be_removed) + end + + test "redirects to the configured path", %{conn: conn, user: user} do + conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user) + assert redirected_to(conn) == "/hello" + end + + test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do + conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie] + + assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] + assert signed_token != get_session(conn, :user_token) + assert max_age == 5_184_000 + end + end + + describe "logout_user/1" do + test "erases session and cookies", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + + conn = + conn + |> put_session(:user_token, user_token) + |> put_req_cookie(@remember_me_cookie, user_token) + |> fetch_cookies() + |> UserAuth.log_out_user() + + refute get_session(conn, :user_token) + refute conn.cookies[@remember_me_cookie] + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == "/" + refute Accounts.get_user_by_session_token(user_token) + end + + test "broadcasts to the given live_socket_id", %{conn: conn} do + live_socket_id = "users_sessions:abcdef-token" + ClaperWeb.Endpoint.subscribe(live_socket_id) + + conn + |> put_session(:live_socket_id, live_socket_id) + |> UserAuth.log_out_user() + + assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id} + end + + test "works even if user is already logged out", %{conn: conn} do + conn = conn |> fetch_cookies() |> UserAuth.log_out_user() + refute get_session(conn, :user_token) + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == "/" + end + end + + describe "fetch_current_user/2" do + test "authenticates user from session", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + conn = conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_user([]) + assert conn.assigns.current_user.id == user.id + end + + test "authenticates user from cookies", %{conn: conn, user: user} do + logged_in_conn = + conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + + user_token = logged_in_conn.cookies[@remember_me_cookie] + %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie] + + conn = + conn + |> put_req_cookie(@remember_me_cookie, signed_token) + |> UserAuth.fetch_current_user([]) + + assert get_session(conn, :user_token) == user_token + assert conn.assigns.current_user.id == user.id + end + + test "does not authenticate if data is missing", %{conn: conn, user: user} do + _ = Accounts.generate_user_session_token(user) + conn = UserAuth.fetch_current_user(conn, []) + refute get_session(conn, :user_token) + refute conn.assigns.current_user + end + end + + describe "redirect_if_user_is_authenticated/2" do + test "redirects if user is authenticated", %{conn: conn, user: user} do + conn = conn |> assign(:current_user, user) |> UserAuth.redirect_if_user_is_authenticated([]) + assert conn.halted + assert redirected_to(conn) == "/" + end + + test "does not redirect if user is not authenticated", %{conn: conn} do + conn = UserAuth.redirect_if_user_is_authenticated(conn, []) + refute conn.halted + refute conn.status + end + end + + describe "require_authenticated_user/2" do + test "redirects if user is not authenticated", %{conn: conn} do + conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([]) + assert conn.halted + assert redirected_to(conn) == Routes.user_session_path(conn, :new) + assert get_flash(conn, :error) == "You must log in to access this page." + end + + test "stores the path to redirect to on GET", %{conn: conn} do + halted_conn = + %{conn | path_info: ["foo"], query_string: ""} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + assert get_session(halted_conn, :user_return_to) == "/foo" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar=baz"} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar", method: "POST"} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + refute get_session(halted_conn, :user_return_to) + end + + test "does not redirect if user is authenticated", %{conn: conn, user: user} do + conn = conn |> assign(:current_user, user) |> UserAuth.require_authenticated_user([]) + refute conn.halted + refute conn.status + end + end +end diff --git a/test/claper_web/controllers/user_confirmation_controller_test.exs b/test/claper_web/controllers/user_confirmation_controller_test.exs new file mode 100644 index 0000000..aa07c3b --- /dev/null +++ b/test/claper_web/controllers/user_confirmation_controller_test.exs @@ -0,0 +1,105 @@ +defmodule ClaperWeb.UserConfirmationControllerTest do + use ClaperWeb.ConnCase, async: true + + alias Claper.Accounts + alias Claper.Repo + import Claper.AccountsFixtures + + setup do + %{user: user_fixture()} + end + + describe "GET /users/confirm" do + test "renders the resend confirmation page", %{conn: conn} do + conn = get(conn, Routes.user_confirmation_path(conn, :new)) + response = html_response(conn, 200) + assert response =~ "

    Resend confirmation instructions

    " + end + end + + describe "POST /users/confirm" do + @tag :capture_log + test "sends a new confirmation token", %{conn: conn, user: user} do + conn = + post(conn, Routes.user_confirmation_path(conn, :create), %{ + "user" => %{"email" => user.email} + }) + + assert redirected_to(conn) == "/" + assert get_flash(conn, :info) =~ "If your email is in our system" + assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "confirm" + end + + test "does not send confirmation token if User is confirmed", %{conn: conn, user: user} do + Repo.update!(Accounts.User.confirm_changeset(user)) + + conn = + post(conn, Routes.user_confirmation_path(conn, :create), %{ + "user" => %{"email" => user.email} + }) + + assert redirected_to(conn) == "/" + assert get_flash(conn, :info) =~ "If your email is in our system" + refute Repo.get_by(Accounts.UserToken, user_id: user.id) + end + + test "does not send confirmation token if email is invalid", %{conn: conn} do + conn = + post(conn, Routes.user_confirmation_path(conn, :create), %{ + "user" => %{"email" => "unknown@example.com"} + }) + + assert redirected_to(conn) == "/" + assert get_flash(conn, :info) =~ "If your email is in our system" + assert Repo.all(Accounts.UserToken) == [] + end + end + + describe "GET /users/confirm/:token" do + test "renders the confirmation page", %{conn: conn} do + conn = get(conn, Routes.user_confirmation_path(conn, :edit, "some-token")) + response = html_response(conn, 200) + assert response =~ "

    Confirm account

    " + + form_action = Routes.user_confirmation_path(conn, :update, "some-token") + assert response =~ "action=\"#{form_action}\"" + end + end + + describe "POST /users/confirm/:token" do + test "confirms the given token once", %{conn: conn, user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + conn = post(conn, Routes.user_confirmation_path(conn, :update, token)) + assert redirected_to(conn) == "/" + assert get_flash(conn, :info) =~ "User confirmed successfully" + assert Accounts.get_user!(user.id).confirmed_at + refute get_session(conn, :user_token) + assert Repo.all(Accounts.UserToken) == [] + + # When not logged in + conn = post(conn, Routes.user_confirmation_path(conn, :update, token)) + assert redirected_to(conn) == "/" + assert get_flash(conn, :error) =~ "User confirmation link is invalid or it has expired" + + # When logged in + conn = + build_conn() + |> log_in_user(user) + |> post(Routes.user_confirmation_path(conn, :update, token)) + + assert redirected_to(conn) == "/" + refute get_flash(conn, :error) + end + + test "does not confirm email with invalid token", %{conn: conn, user: user} do + conn = post(conn, Routes.user_confirmation_path(conn, :update, "oops")) + assert redirected_to(conn) == "/" + assert get_flash(conn, :error) =~ "User confirmation link is invalid or it has expired" + refute Accounts.get_user!(user.id).confirmed_at + end + end +end diff --git a/test/claper_web/controllers/user_registration_controller_test.exs b/test/claper_web/controllers/user_registration_controller_test.exs new file mode 100644 index 0000000..f3301e9 --- /dev/null +++ b/test/claper_web/controllers/user_registration_controller_test.exs @@ -0,0 +1,54 @@ +defmodule ClaperWeb.UserRegistrationControllerTest do + use ClaperWeb.ConnCase, async: true + + import Claper.AccountsFixtures + + describe "GET /users/register" do + test "renders registration page", %{conn: conn} do + conn = get(conn, Routes.user_registration_path(conn, :new)) + response = html_response(conn, 200) + assert response =~ "

    Register

    " + assert response =~ "Log in" + assert response =~ "Register" + end + + test "redirects if already logged in", %{conn: conn} do + conn = conn |> log_in_user(user_fixture()) |> get(Routes.user_registration_path(conn, :new)) + assert redirected_to(conn) == "/" + end + end + + describe "POST /users/register" do + @tag :capture_log + test "creates account and logs the user in", %{conn: conn} do + email = unique_user_email() + + conn = + post(conn, Routes.user_registration_path(conn, :create), %{ + "user" => valid_user_attributes(email: email) + }) + + assert get_session(conn, :user_token) + assert redirected_to(conn) == "/" + + # Now do a logged in request and assert on the menu + conn = get(conn, "/") + response = html_response(conn, 200) + assert response =~ email + assert response =~ "Settings" + assert response =~ "Log out" + end + + test "render errors for invalid data", %{conn: conn} do + conn = + post(conn, Routes.user_registration_path(conn, :create), %{ + "user" => %{"email" => "with spaces", "password" => "short"} + }) + + response = html_response(conn, 200) + assert response =~ "

    Register

    " + assert response =~ "must have the @ sign and no spaces" + assert response =~ "should be at least 6 character" + end + end +end diff --git a/test/claper_web/controllers/user_reset_password_controller_test.exs b/test/claper_web/controllers/user_reset_password_controller_test.exs new file mode 100644 index 0000000..e8f0c9a --- /dev/null +++ b/test/claper_web/controllers/user_reset_password_controller_test.exs @@ -0,0 +1,113 @@ +defmodule ClaperWeb.UserResetPasswordControllerTest do + use ClaperWeb.ConnCase, async: true + + alias Claper.Accounts + alias Claper.Repo + import Claper.AccountsFixtures + + setup do + %{user: user_fixture()} + end + + describe "GET /users/reset_password" do + test "renders the reset password page", %{conn: conn} do + conn = get(conn, Routes.user_reset_password_path(conn, :new)) + response = html_response(conn, 200) + assert response =~ "

    Forgot your password?

    " + end + end + + describe "POST /users/reset_password" do + @tag :capture_log + test "sends a new reset password token", %{conn: conn, user: user} do + conn = + post(conn, Routes.user_reset_password_path(conn, :create), %{ + "user" => %{"email" => user.email} + }) + + assert redirected_to(conn) == "/" + assert get_flash(conn, :info) =~ "If your email is in our system" + assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "reset_password" + end + + test "does not send reset password token if email is invalid", %{conn: conn} do + conn = + post(conn, Routes.user_reset_password_path(conn, :create), %{ + "user" => %{"email" => "unknown@example.com"} + }) + + assert redirected_to(conn) == "/" + assert get_flash(conn, :info) =~ "If your email is in our system" + assert Repo.all(Accounts.UserToken) == [] + end + end + + describe "GET /users/reset_password/:token" do + setup %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + %{token: token} + end + + test "renders reset password", %{conn: conn, token: token} do + conn = get(conn, Routes.user_reset_password_path(conn, :edit, token)) + assert html_response(conn, 200) =~ "

    Reset password

    " + end + + test "does not render reset password with invalid token", %{conn: conn} do + conn = get(conn, Routes.user_reset_password_path(conn, :edit, "oops")) + assert redirected_to(conn) == "/" + assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired" + end + end + + describe "PUT /users/reset_password/:token" do + setup %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + %{token: token} + end + + test "resets password once", %{conn: conn, user: user, token: token} do + conn = + put(conn, Routes.user_reset_password_path(conn, :update, token), %{ + "user" => %{ + "password" => "new valid password", + "password_confirmation" => "new valid password" + } + }) + + assert redirected_to(conn) == Routes.user_session_path(conn, :new) + refute get_session(conn, :user_token) + assert get_flash(conn, :info) =~ "Password reset successfully" + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "does not reset password on invalid data", %{conn: conn, token: token} do + conn = + put(conn, Routes.user_reset_password_path(conn, :update, token), %{ + "user" => %{ + "password" => "short", + "password_confirmation" => "does not match" + } + }) + + response = html_response(conn, 200) + assert response =~ "

    Reset password

    " + assert response =~ "should be at least 6 character(s)" + assert response =~ "does not match password" + end + + test "does not reset password with invalid token", %{conn: conn} do + conn = put(conn, Routes.user_reset_password_path(conn, :update, "oops")) + assert redirected_to(conn) == "/" + assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired" + end + end +end diff --git a/test/claper_web/controllers/user_session_controller_test.exs b/test/claper_web/controllers/user_session_controller_test.exs new file mode 100644 index 0000000..1f99da0 --- /dev/null +++ b/test/claper_web/controllers/user_session_controller_test.exs @@ -0,0 +1,98 @@ +defmodule ClaperWeb.UserSessionControllerTest do + use ClaperWeb.ConnCase, async: true + + import Claper.AccountsFixtures + + setup do + %{user: user_fixture()} + end + + describe "GET /users/log_in" do + test "renders log in page", %{conn: conn} do + conn = get(conn, Routes.user_session_path(conn, :new)) + response = html_response(conn, 200) + assert response =~ "

    Log in

    " + assert response =~ "Register" + assert response =~ "Forgot your password?" + end + + test "redirects if already logged in", %{conn: conn, user: user} do + conn = conn |> log_in_user(user) |> get(Routes.user_session_path(conn, :new)) + assert redirected_to(conn) == "/" + end + end + + describe "POST /users/log_in" do + test "logs the user in", %{conn: conn, user: user} do + conn = + post(conn, Routes.user_session_path(conn, :create), %{ + "user" => %{"email" => user.email, "password" => valid_user_password()} + }) + + assert get_session(conn, :user_token) + assert redirected_to(conn) == "/" + + # Now do a logged in request and assert on the menu + conn = get(conn, "/") + response = html_response(conn, 200) + assert response =~ user.email + assert response =~ "Settings" + assert response =~ "Log out" + end + + test "logs the user in with remember me", %{conn: conn, user: user} do + conn = + post(conn, Routes.user_session_path(conn, :create), %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password(), + "remember_me" => "true" + } + }) + + assert conn.resp_cookies["_claper_web_user_remember_me"] + assert redirected_to(conn) == "/" + end + + test "logs the user in with return to", %{conn: conn, user: user} do + conn = + conn + |> init_test_session(user_return_to: "/foo/bar") + |> post(Routes.user_session_path(conn, :create), %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == "/foo/bar" + end + + test "emits error message with invalid credentials", %{conn: conn, user: user} do + conn = + post(conn, Routes.user_session_path(conn, :create), %{ + "user" => %{"email" => user.email, "password" => "invalid_password"} + }) + + response = html_response(conn, 200) + assert response =~ "

    Log in

    " + assert response =~ "Invalid email or password" + end + end + + describe "DELETE /users/log_out" do + test "logs the user out", %{conn: conn, user: user} do + conn = conn |> log_in_user(user) |> delete(Routes.user_session_path(conn, :delete)) + assert redirected_to(conn) == "/" + refute get_session(conn, :user_token) + assert get_flash(conn, :info) =~ "Logged out successfully" + end + + test "succeeds even if the user is not logged in", %{conn: conn} do + conn = delete(conn, Routes.user_session_path(conn, :delete)) + assert redirected_to(conn) == "/" + refute get_session(conn, :user_token) + assert get_flash(conn, :info) =~ "Logged out successfully" + end + end +end diff --git a/test/claper_web/controllers/user_settings_controller_test.exs b/test/claper_web/controllers/user_settings_controller_test.exs new file mode 100644 index 0000000..032adb7 --- /dev/null +++ b/test/claper_web/controllers/user_settings_controller_test.exs @@ -0,0 +1,129 @@ +defmodule ClaperWeb.UserSettingsControllerTest do + use ClaperWeb.ConnCase, async: true + + alias Claper.Accounts + import Claper.AccountsFixtures + + setup :register_and_log_in_user + + describe "GET /users/settings" do + test "renders settings page", %{conn: conn} do + conn = get(conn, Routes.user_settings_path(conn, :edit)) + response = html_response(conn, 200) + assert response =~ "

    Settings

    " + end + + test "redirects if user is not logged in" do + conn = build_conn() + conn = get(conn, Routes.user_settings_path(conn, :edit)) + assert redirected_to(conn) == Routes.user_session_path(conn, :new) + end + end + + describe "PUT /users/settings (change password form)" do + test "updates the user password and resets tokens", %{conn: conn, user: user} do + new_password_conn = + put(conn, Routes.user_settings_path(conn, :update), %{ + "action" => "update_password", + "current_password" => valid_user_password(), + "user" => %{ + "password" => "new valid password", + "password_confirmation" => "new valid password" + } + }) + + assert redirected_to(new_password_conn) == Routes.user_settings_path(conn, :edit) + assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token) + assert get_flash(new_password_conn, :info) =~ "Password updated successfully" + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "does not update password on invalid data", %{conn: conn} do + old_password_conn = + put(conn, Routes.user_settings_path(conn, :update), %{ + "action" => "update_password", + "current_password" => "invalid", + "user" => %{ + "password" => "short", + "password_confirmation" => "does not match" + } + }) + + response = html_response(old_password_conn, 200) + assert response =~ "

    Settings

    " + assert response =~ "should be at least 6 character(s)" + assert response =~ "does not match password" + assert response =~ "is not valid" + + assert get_session(old_password_conn, :user_token) == get_session(conn, :user_token) + end + end + + describe "PUT /users/settings (change email form)" do + @tag :capture_log + test "updates the user email", %{conn: conn, user: user} do + conn = + put(conn, Routes.user_settings_path(conn, :update), %{ + "action" => "update_email", + "current_password" => valid_user_password(), + "user" => %{"email" => unique_user_email()} + }) + + assert redirected_to(conn) == Routes.user_settings_path(conn, :edit) + assert get_flash(conn, :info) =~ "A link to confirm your email" + assert Accounts.get_user_by_email(user.email) + end + + test "does not update email on invalid data", %{conn: conn} do + conn = + put(conn, Routes.user_settings_path(conn, :update), %{ + "action" => "update_email", + "current_password" => "invalid", + "user" => %{"email" => "with spaces"} + }) + + response = html_response(conn, 200) + assert response =~ "

    Settings

    " + assert response =~ "must have the @ sign and no spaces" + assert response =~ "is not valid" + end + end + + describe "GET /users/settings/confirm_email/:token" do + setup %{user: user} do + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{token: token, email: email} + end + + test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do + conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token)) + assert redirected_to(conn) == Routes.user_settings_path(conn, :edit) + assert get_flash(conn, :info) =~ "Email changed successfully" + refute Accounts.get_user_by_email(user.email) + assert Accounts.get_user_by_email(email) + + conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token)) + assert redirected_to(conn) == Routes.user_settings_path(conn, :edit) + assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired" + end + + test "does not update email with invalid token", %{conn: conn, user: user} do + conn = get(conn, Routes.user_settings_path(conn, :confirm_email, "oops")) + assert redirected_to(conn) == Routes.user_settings_path(conn, :edit) + assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired" + assert Accounts.get_user_by_email(user.email) + end + + test "redirects if user is not logged in", %{token: token} do + conn = build_conn() + conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token)) + assert redirected_to(conn) == Routes.user_session_path(conn, :new) + end + end +end diff --git a/test/claper_web/live/event_live_test.exs b/test/claper_web/live/event_live_test.exs new file mode 100644 index 0000000..dd345db --- /dev/null +++ b/test/claper_web/live/event_live_test.exs @@ -0,0 +1,110 @@ +defmodule ClaperWeb.EventLiveTest do + use ClaperWeb.ConnCase + + import Phoenix.LiveViewTest + import Claper.{EventsFixtures} + + @create_attrs %{name: "some name"} + @update_attrs %{name: "some updated name"} + @invalid_attrs %{name: nil} + + defp create_event(params) do + event = event_fixture(%{}, [:org]) + params |> Map.put(:event, event) |> Map.put(:org_id, event.org.id) + end + + describe "Index" do + setup [:create_event, :register_and_log_in_user] + + test "lists all events", %{conn: conn, event: event} do + {:ok, _index_live, html} = live(conn, Routes.event_index_path(conn, :index)) + + assert html =~ "Listing Events" + assert html =~ event.name + end + + test "saves new event", %{conn: conn} do + {:ok, index_live, _html} = live(conn, Routes.event_index_path(conn, :index)) + + assert index_live |> element("a", "New Event") |> render_click() =~ + "New Event" + + assert_patch(index_live, Routes.event_index_path(conn, :new)) + + assert index_live + |> form("#event-form", event: @invalid_attrs) + |> render_change() =~ "can't be blank" + + {:ok, _, html} = + index_live + |> form("#event-form", event: @create_attrs) + |> render_submit() + |> follow_redirect(conn, Routes.event_index_path(conn, :index)) + + assert html =~ "Event created successfully" + assert html =~ "some name" + end + + test "updates event in listing", %{conn: conn, event: event} do + {:ok, index_live, _html} = live(conn, Routes.event_index_path(conn, :index)) + + assert index_live |> element("#event-#{event.uuid} a", "Edit") |> render_click() =~ + "Edit Event" + + assert_patch(index_live, Routes.event_index_path(conn, :edit, event.uuid)) + + assert index_live + |> form("#event-form", event: @invalid_attrs) + |> render_change() =~ "can't be blank" + + {:ok, _, html} = + index_live + |> form("#event-form", event: @update_attrs) + |> render_submit() + |> follow_redirect(conn, Routes.event_index_path(conn, :index)) + + assert html =~ "Event updated successfully" + assert html =~ "some updated name" + end + + test "deletes event in listing", %{conn: conn, event: event} do + {:ok, index_live, _html} = live(conn, Routes.event_index_path(conn, :index)) + + assert index_live |> element("#event-#{event.uuid} a", "Delete") |> render_click() + refute has_element?(index_live, "#event-#{event.uuid}") + end + end + + describe "Show" do + setup [:create_event, :register_and_log_in_user] + + test "displays event", %{conn: conn, event: event} do + {:ok, _show_live, html} = live(conn, Routes.event_show_path(conn, :show, event.uuid)) + + assert html =~ "Show Event" + assert html =~ event.name + end + + test "updates event within modal", %{conn: conn, event: event} do + {:ok, show_live, _html} = live(conn, Routes.event_show_path(conn, :show, event.uuid)) + + assert show_live |> element("a", "Edit") |> render_click() =~ + "Edit Event" + + assert_patch(show_live, Routes.event_show_path(conn, :edit, event.uuid)) + + assert show_live + |> form("#event-form", event: @invalid_attrs) + |> render_change() =~ "can't be blank" + + {:ok, _, html} = + show_live + |> form("#event-form", event: @update_attrs) + |> render_submit() + |> follow_redirect(conn, Routes.event_show_path(conn, :show, event.uuid)) + + assert html =~ "Event updated successfully" + assert html =~ "some updated name" + end + end +end diff --git a/test/claper_web/live/post_live_test.exs b/test/claper_web/live/post_live_test.exs new file mode 100644 index 0000000..e26dd66 --- /dev/null +++ b/test/claper_web/live/post_live_test.exs @@ -0,0 +1,110 @@ +defmodule ClaperWeb.PostLiveTest do + use ClaperWeb.ConnCase + + import Phoenix.LiveViewTest + import Claper.PostsFixtures + + @create_attrs %{body: "some body"} + @update_attrs %{body: "some updated body"} + @invalid_attrs %{body: nil} + + defp create_post(params) do + post = post_fixture(%{}, [:room]) + params |> Map.put(:post, post) |> Map.put(:org_id, post.org_id) + end + + describe "Index" do + setup [:create_post, :register_and_log_in_user] + + test "lists all posts", %{conn: conn, post: post} do + {:ok, _index_live, html} = live(conn, Routes.post_index_path(conn, :index, post.room.uuid)) + + assert html =~ "Listing Posts" + assert html =~ post.body + end + + test "saves new post", %{conn: conn, post: post} do + {:ok, index_live, _html} = live(conn, Routes.post_index_path(conn, :index, post.room.uuid)) + + assert index_live |> element("a", "New Post") |> render_click() =~ + "New Post" + + assert_patch(index_live, Routes.post_index_path(conn, :new, post.room.uuid)) + + assert index_live + |> form("#post-form", post: @invalid_attrs) + |> render_change() =~ "can't be blank" + + {:ok, _, html} = + index_live + |> form("#post-form", post: @create_attrs) + |> render_submit() + |> follow_redirect(conn, Routes.post_index_path(conn, :index, post.room.uuid)) + + assert html =~ "Post created successfully" + assert html =~ "some body" + end + + test "updates post in listing", %{conn: conn, post: post} do + {:ok, index_live, _html} = live(conn, Routes.post_index_path(conn, :index, post.room.uuid)) + + assert index_live |> element("#post-#{post.uuid} a", "Edit") |> render_click() =~ + "Edit Post" + + assert_patch(index_live, Routes.post_index_path(conn, :edit, post.uuid)) + + assert index_live + |> form("#post-form", post: @invalid_attrs) + |> render_change() =~ "can't be blank" + + {:ok, _, html} = + index_live + |> form("#post-form", post: @update_attrs) + |> render_submit() + |> follow_redirect(conn, Routes.post_index_path(conn, :index, post.room.uuid)) + + assert html =~ "Post updated successfully" + assert html =~ "some updated body" + end + + test "deletes post in listing", %{conn: conn, post: post} do + {:ok, index_live, _html} = live(conn, Routes.post_index_path(conn, :index, post.room.uuid)) + + assert index_live |> element("#post-#{post.uuid} a", "Delete") |> render_click() + refute index_live |> element("#post-#{post.uuid} a", "Delete") |> has_element? + end + end + + describe "Show" do + setup [:create_post, :register_and_log_in_user] + + test "displays post", %{conn: conn, post: post} do + {:ok, _show_live, html} = live(conn, Routes.post_show_path(conn, :show, post.uuid)) + + assert html =~ "Show Post" + assert html =~ post.body + end + + test "updates post within modal", %{conn: conn, post: post} do + {:ok, show_live, _html} = live(conn, Routes.post_show_path(conn, :show, post.uuid)) + + assert show_live |> element("a", "Edit") |> render_click() =~ + "Edit Post" + + assert_patch(show_live, Routes.post_show_path(conn, :edit, post.uuid)) + + assert show_live + |> form("#post-form", post: @invalid_attrs) + |> render_change() =~ "can't be blank" + + {:ok, _, html} = + show_live + |> form("#post-form", post: @update_attrs) + |> render_submit() + |> follow_redirect(conn, Routes.post_show_path(conn, :show, post.uuid)) + + assert html =~ "Post updated successfully" + assert html =~ "some updated body" + end + end +end diff --git a/test/claper_web/views/error_view_test.exs b/test/claper_web/views/error_view_test.exs new file mode 100644 index 0000000..f700a4d --- /dev/null +++ b/test/claper_web/views/error_view_test.exs @@ -0,0 +1,14 @@ +defmodule ClaperWeb.ErrorViewTest do + use ClaperWeb.ConnCase, async: true + + # Bring render/3 and render_to_string/3 for testing custom views + import Phoenix.View + + test "renders 404.html" do + assert render_to_string(ClaperWeb.ErrorView, "404.html", []) == "Not Found" + end + + test "renders 500.html" do + assert render_to_string(ClaperWeb.ErrorView, "500.html", []) == "Internal Server Error" + end +end diff --git a/test/claper_web/views/layout_view_test.exs b/test/claper_web/views/layout_view_test.exs new file mode 100644 index 0000000..2c735a5 --- /dev/null +++ b/test/claper_web/views/layout_view_test.exs @@ -0,0 +1,8 @@ +defmodule ClaperWeb.LayoutViewTest do + use ClaperWeb.ConnCase, async: true + + # When testing helpers, you may want to import Phoenix.HTML and + # use functions such as safe_to_string() to convert the helper + # result into an HTML string. + # import Phoenix.HTML +end diff --git a/test/claper_web/views/page_view_test.exs b/test/claper_web/views/page_view_test.exs new file mode 100644 index 0000000..01565c2 --- /dev/null +++ b/test/claper_web/views/page_view_test.exs @@ -0,0 +1,3 @@ +defmodule ClaperWeb.PageViewTest do + use ClaperWeb.ConnCase, async: true +end diff --git a/test/support/channel_case.ex b/test/support/channel_case.ex new file mode 100644 index 0000000..6bb0b4b --- /dev/null +++ b/test/support/channel_case.ex @@ -0,0 +1,36 @@ +defmodule ClaperWeb.ChannelCase do + @moduledoc """ + This module defines the test case to be used by + channel tests. + + Such tests rely on `Phoenix.ChannelTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use ClaperWeb.ChannelCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # Import conveniences for testing with channels + import Phoenix.ChannelTest + import ClaperWeb.ChannelCase + + # The default endpoint for testing + @endpoint ClaperWeb.Endpoint + end + end + + setup tags do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Claper.Repo, shared: not tags[:async]) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + :ok + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex new file mode 100644 index 0000000..cbb989d --- /dev/null +++ b/test/support/conn_case.ex @@ -0,0 +1,71 @@ +defmodule ClaperWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use ClaperWeb.ConnCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # Import conveniences for testing with connections + import Plug.Conn + import Phoenix.ConnTest + import ClaperWeb.ConnCase + + alias ClaperWeb.Router.Helpers, as: Routes + + # The default endpoint for testing + @endpoint ClaperWeb.Endpoint + end + end + + setup tags do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Claper.Repo, shared: not tags[:async]) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + {:ok, conn: Phoenix.ConnTest.build_conn()} + end + + @doc """ + Setup helper that registers and logs in users. + + setup :register_and_log_in_user + + It stores an updated connection and a registered user in the + test context. + """ + def register_and_log_in_user(%{conn: conn, org_id: org_id}) do + user = Claper.AccountsFixtures.user_fixture(%{confirmed_at: DateTime.utc_now(), org_id: org_id}) + %{conn: log_in_user(conn, user), user: user} + end + + def register_and_log_in_user(%{conn: conn}) do + user = Claper.AccountsFixtures.user_fixture(%{confirmed_at: DateTime.utc_now()}) + %{conn: log_in_user(conn, user), user: user} + end + + @doc """ + Logs the given `user` into the `conn`. + + It returns an updated `conn`. + """ + def log_in_user(conn, user) do + token = Claper.Accounts.generate_user_session_token(user) + + conn + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_session(:user_token, token) + |> Plug.Conn.put_session(:current_user, user) + end +end diff --git a/test/support/data_case.ex b/test/support/data_case.ex new file mode 100644 index 0000000..62267d8 --- /dev/null +++ b/test/support/data_case.ex @@ -0,0 +1,51 @@ +defmodule Claper.DataCase do + @moduledoc """ + This module defines the setup for tests requiring + access to the application's data layer. + + You may define functions here to be used as helpers in + your tests. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use Claper.DataCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + alias Claper.Repo + + import Ecto + import Ecto.Changeset + import Ecto.Query + import Claper.DataCase + end + end + + setup tags do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Claper.Repo, shared: not tags[:async]) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + :ok + end + + @doc """ + A helper that transforms changeset errors into a map of messages. + + assert {:error, changeset} = Accounts.create_user(%{password: "short"}) + assert "password is too short" in errors_on(changeset).password + assert %{password: ["password is too short"]} = errors_on(changeset) + + """ + def errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex new file mode 100644 index 0000000..6a525a3 --- /dev/null +++ b/test/support/fixtures/accounts_fixtures.ex @@ -0,0 +1,35 @@ +defmodule Claper.AccountsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Claper.Accounts` context. + """ + + def unique_user_email, do: "user#{System.unique_integer()}@example.com" + + def valid_user_attributes(attrs \\ %{}) do + Enum.into(attrs, %{ + email: unique_user_email() + }) + end + + def user_fixture(attrs \\ %{}) do + {:ok, user} = + attrs + |> valid_user_attributes() + |> Claper.Accounts.register_user() + + user + end + + def extract_magic_token(fun) do + {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]") + [_, token | _] = String.split(captured_email.html_body, "[TOKEN]") + token + end + + def extract_user_token(fun) do + {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]") + [_, token | _] = String.split(captured_email.html_body || captured_email.text_body, "[TOKEN]") + token + end +end diff --git a/test/support/fixtures/events_fixtures.ex b/test/support/fixtures/events_fixtures.ex new file mode 100644 index 0000000..0126fd2 --- /dev/null +++ b/test/support/fixtures/events_fixtures.ex @@ -0,0 +1,31 @@ +defmodule Claper.EventsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Claper.Events` context. + """ + + import Claper.{AccountsFixtures} + + require Claper.UtilFixture + + @doc """ + Generate a event. + """ + def event_fixture(attrs \\ %{}, preload \\ []) do + assoc = %{user: user_fixture()} + {:ok, event} = + attrs + |> Enum.into(%{ + name: "some name", + code: "#{Enum.random(1000..2000)}", + uuid: Ecto.UUID.generate(), + user_id: assoc.user.id, + started_at: NaiveDateTime.utc_now, + expired_at: NaiveDateTime.add(NaiveDateTime.utc_now, 7200, :second) # add 2 hours + }) + |> Claper.Events.create_event() + + Claper.UtilFixture.merge_preload(event, preload, assoc) + end + +end diff --git a/test/support/fixtures/polls_fixtures.ex b/test/support/fixtures/polls_fixtures.ex new file mode 100644 index 0000000..3313a78 --- /dev/null +++ b/test/support/fixtures/polls_fixtures.ex @@ -0,0 +1,51 @@ +defmodule Claper.PollsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Claper.Polls` context. + """ + + import Claper.{AccountsFixtures,PresentationsFixtures} + + require Claper.UtilFixture + + @doc """ + Generate a poll. + """ + def poll_fixture(attrs \\ %{}, preload \\ []) do + {:ok, poll} = + attrs + |> Enum.into(%{ + title: "some title", + position: 0, + enabled: true, + poll_opts: [ + %{content: "some option 1", vote_count: 0}, + %{content: "some option 2", vote_count: 0}, + ] + }) + |> Claper.Polls.create_poll() + + Claper.UtilFixture.merge_preload(poll, preload, %{}) + + end + + @doc """ + Generate a poll_vote. + """ + def poll_vote_fixture(attrs \\ %{}) do + presentation_file = presentation_file_fixture() + poll = poll_fixture(%{presentation_file_id: presentation_file.id}) + [poll_opt | _] = poll.poll_opts + assoc = %{poll: poll} + {:ok, poll_vote} = + attrs + |> Enum.into(%{ + poll_id: assoc.poll.id, + poll_opt_id: poll_opt.id, + user_id: user_fixture().id + }) + |> Claper.Polls.create_poll_vote() + + poll_vote + end +end diff --git a/test/support/fixtures/posts_fixtures.ex b/test/support/fixtures/posts_fixtures.ex new file mode 100644 index 0000000..8875cde --- /dev/null +++ b/test/support/fixtures/posts_fixtures.ex @@ -0,0 +1,45 @@ +defmodule Claper.PostsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Claper.Posts` context. + """ + + import Claper.{AccountsFixtures, EventsFixtures} + + require Claper.UtilFixture + + @doc """ + Generate a post. + """ + def post_fixture(attrs \\ %{}, preload \\ []) do + user = user_fixture() + event = event_fixture() + assoc = %{user: user, event: event} + {:ok, post} = + Claper.Posts.create_post(assoc.event, attrs + |> Enum.into(%{ + body: "some body", + like_count: 42, + position: 0, + uuid: Ecto.UUID.generate(), + user_id: assoc.user.id + })) + + Claper.UtilFixture.merge_preload(post, preload, assoc) + end + + + @doc """ + Generate a reaction. + """ + def reaction_fixture(attrs \\ %{}) do + {:ok, reaction} = + attrs + |> Enum.into(%{ + icon: "some icon" + }) + |> Claper.Posts.create_reaction() + + reaction + end +end diff --git a/test/support/fixtures/presentations_fixtures.ex b/test/support/fixtures/presentations_fixtures.ex new file mode 100644 index 0000000..4bda125 --- /dev/null +++ b/test/support/fixtures/presentations_fixtures.ex @@ -0,0 +1,47 @@ +defmodule Claper.PresentationsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Claper.Presentations` context. + """ + + import Claper.{EventsFixtures} + + require Claper.UtilFixture + + @doc """ + Generate a presentation_file. + """ + def presentation_file_fixture(attrs \\ %{}, preload \\ []) do + assoc = %{event: event_fixture()} + {:ok, presentation_file} = + attrs + |> Enum.into(%{ + hash: "123456", + length: 42, + status: "done", + event_id: assoc.event.id + }) + |> Claper.Presentations.create_presentation_file() + + Claper.UtilFixture.merge_preload(presentation_file, preload, assoc) + end + + @doc """ + Generate a presentation_state. + """ + def presentation_state_fixture(attrs \\ %{}) do + assoc = %{presentation_file: presentation_file_fixture()} + {:ok, presentation_state} = + attrs + |> Enum.into(%{ + presentation_file_id: assoc.presentation_file.id, + position: 0, + chat_visible: false, + poll_visible: false, + join_screen_visible: false + }) + |> Claper.Presentations.create_presentation_state() + + presentation_state + end +end diff --git a/test/support/util_fixture.ex b/test/support/util_fixture.ex new file mode 100644 index 0000000..2de9a89 --- /dev/null +++ b/test/support/util_fixture.ex @@ -0,0 +1,8 @@ +defmodule Claper.UtilFixture do + defmacro merge_preload(origin, preload, assoc) do + quote do + unquote(origin) |> + Map.merge(for p <- unquote(preload), unquote(assoc)[p], into: %{}, do: {p, unquote(assoc)[p]}) + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..da0d57f --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(Claper.Repo, :manual)