├── .credo.exs ├── .env.dev.sample ├── .formatter.exs ├── .github ├── ISSUE_TEMPLATE │ ├── 0-feature_request.yml │ └── 1-bug_report.yml ├── actions │ └── action.yml ├── brand │ ├── atomic-DARK.svg │ ├── atomic-LIGHT.svg │ ├── atomic.ai │ ├── cesium-BLACK.svg │ ├── cesium-DARK.svg │ ├── cesium-LIGHT.svg │ ├── cesium-ORANGE.svg │ ├── cesium-WHITE.svg │ ├── logo-BLACK.svg │ ├── logo-ORANGE.svg │ └── logo-WHITE.svg ├── pull_request_template.md └── workflows │ ├── style.yml │ └── test.yml ├── .gitignore ├── .tool-versions ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile.dev ├── LICENSE.txt ├── README.md ├── assets ├── css │ ├── app.css │ ├── components.css │ ├── components │ │ ├── avatar.css │ │ ├── badge.css │ │ ├── button.css │ │ ├── field.css │ │ ├── forms.css │ │ └── spinner.css │ └── storybook.css ├── js │ ├── app.js │ ├── hooks │ │ ├── index.js │ │ ├── qr_reading.js │ │ ├── scroll_to_top.js │ │ ├── sorting.js │ │ └── sticky_scroll.js │ ├── shims │ │ └── phx_feedback_dom.js │ └── storybook.js ├── tailwind.config.js └── vendor │ ├── alpine.js │ ├── html5-qrcode.js │ ├── sortable.js │ └── topbar.js ├── bin ├── console ├── format ├── lint ├── pre-commit.sh ├── server ├── setup └── test ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── runtime.exs └── test.exs ├── darwin.yml ├── data └── courses.txt ├── docker-compose.dev.yml ├── lib ├── atomic.ex ├── atomic │ ├── accounts.ex │ ├── accounts │ │ ├── course.ex │ │ ├── user.ex │ │ ├── user_notifier.ex │ │ └── user_token.ex │ ├── activities.ex │ ├── activities │ │ ├── activity.ex │ │ └── enrollment.ex │ ├── application.ex │ ├── context.ex │ ├── departments.ex │ ├── feed.ex │ ├── feed │ │ └── post.ex │ ├── generate_avatar.ex │ ├── location │ │ └── location.ex │ ├── mailer.ex │ ├── organizations.ex │ ├── organizations │ │ ├── announcement.ex │ │ ├── collaborator.ex │ │ ├── department.ex │ │ ├── membership.ex │ │ ├── organization.ex │ │ └── partner.ex │ ├── partnerships.ex │ ├── quantum │ │ └── certificate_delivery.ex │ ├── repo.ex │ ├── scheduler.ex │ ├── schema.ex │ ├── socials │ │ └── socials.ex │ ├── time.ex │ ├── uploader.ex │ └── uploaders │ │ ├── banner.ex │ │ ├── logo.ex │ │ ├── partner_image.ex │ │ ├── post.ex │ │ └── profile_picture.ex ├── atomic_web.ex └── atomic_web │ ├── components │ ├── activity.ex │ ├── announcement.ex │ ├── avatar.ex │ ├── badge.ex │ ├── button.ex │ ├── dropdown.ex │ ├── empty.ex │ ├── forms.ex │ ├── gradient.ex │ ├── helpers.ex │ ├── icon.ex │ ├── image_uploader.ex │ ├── legal_pages_links.ex │ ├── map.ex │ ├── modal.ex │ ├── notification.ex │ ├── organizations.ex │ ├── page.ex │ ├── pagination.ex │ ├── sidebar.ex │ ├── socials.ex │ ├── spinner.ex │ ├── table.ex │ ├── tabs.ex │ └── unauthenticated.ex │ ├── config.ex │ ├── controllers │ ├── sitemap_controller.ex │ ├── user_auth.ex │ ├── user_change_password_controller.ex │ ├── user_confirmation_controller.ex │ ├── user_registration_controller.ex │ ├── user_reset_password_controller.ex │ ├── user_session_controller.ex │ └── user_setup_controller.ex │ ├── emails │ ├── activity_emails.ex │ └── department_emails.ex │ ├── endpoint.ex │ ├── gettext.ex │ ├── live │ ├── activity_live │ │ ├── edit.ex │ │ ├── edit.html.heex │ │ ├── form_component.ex │ │ ├── form_component.html.heex │ │ ├── index.ex │ │ ├── index.html.heex │ │ ├── map.heex │ │ ├── new.ex │ │ ├── new.html.heex │ │ ├── show.ex │ │ └── show.html.heex │ ├── announcement_live │ │ ├── components │ │ │ └── announcement_card.ex │ │ ├── edit.ex │ │ ├── edit.html.heex │ │ ├── form_component.ex │ │ ├── form_component.html.heex │ │ ├── index.ex │ │ ├── index.html.heex │ │ ├── new.ex │ │ ├── new.html.heex │ │ ├── show.ex │ │ └── show.html.heex │ ├── auth │ │ ├── components │ │ │ └── pitch.ex │ │ ├── user_login_live.ex │ │ └── user_register_live.ex │ ├── calendar_live │ │ ├── components │ │ │ ├── calendar_utils.ex │ │ │ ├── month.ex │ │ │ └── week.ex │ │ ├── show.ex │ │ └── show.html.heex │ ├── collaborator_live │ │ └── form_component.ex │ ├── department_live │ │ ├── components │ │ │ └── department_card.ex │ │ ├── edit.ex │ │ ├── edit.html.heex │ │ ├── form_component.ex │ │ ├── index.ex │ │ ├── index.html.heex │ │ ├── show.ex │ │ └── show.html.heex │ ├── home_live │ │ ├── components │ │ │ ├── follow_suggestions │ │ │ │ ├── follow_suggestions.ex │ │ │ │ └── suggestion.ex │ │ │ └── schedule.ex │ │ ├── index.ex │ │ └── index.html.heex │ ├── hooks.ex │ ├── legal_terms_live │ │ ├── components │ │ │ ├── black_bar.ex │ │ │ ├── header.ex │ │ │ └── main_title.ex │ │ ├── cookies_live │ │ │ ├── show.ex │ │ │ └── show.html.heex │ │ ├── privacy_live │ │ │ ├── show.ex │ │ │ └── show.html.heex │ │ └── tos_live │ │ │ ├── show.ex │ │ │ └── show.html.heex │ ├── live_helpers.ex │ ├── organization_live │ │ ├── edit.ex │ │ ├── edit.html.heex │ │ ├── form_component.ex │ │ ├── form_component.html.heex │ │ ├── index.ex │ │ ├── index.html.heex │ │ ├── new.ex │ │ ├── new.html.heex │ │ ├── show.ex │ │ └── show.html.heex │ ├── partner_live │ │ ├── edit.ex │ │ ├── edit.html.heex │ │ ├── form_component.ex │ │ ├── index.ex │ │ ├── index.html.heex │ │ ├── show.ex │ │ └── show.html.heex │ ├── profile_live │ │ ├── edit.ex │ │ ├── edit.html.heex │ │ ├── form_component.ex │ │ ├── form_component.html.heex │ │ ├── show.ex │ │ └── show.html.heex │ ├── scanner_live │ │ ├── index.ex │ │ └── index.html.heex │ └── user_live │ │ ├── edit.ex │ │ ├── edit.html.heex │ │ ├── form_component.ex │ │ └── form_component.html.heex │ ├── plugs │ ├── authorize.ex │ └── verify_association.ex │ ├── router.ex │ ├── storybook.ex │ ├── telemetry.ex │ ├── templates │ ├── email │ │ ├── activity_certificate.html.eex │ │ ├── collaborator_accepted.html.eex │ │ ├── collaborator_request.html.eex │ │ ├── user_confirmation.html.eex │ │ └── user_reset_password.html.eex │ ├── error │ │ ├── 404.html.heex │ │ └── 500.html.heex │ ├── layout │ │ ├── _live_navbar.html.heex │ │ ├── _user_menu.html.heex │ │ ├── app.html.heex │ │ ├── live.html.heex │ │ └── root.html.heex │ ├── pdf │ │ └── activity_certificate.html.eex │ ├── user_change_password │ │ └── edit.html.heex │ ├── user_confirmation │ │ └── new.html.heex │ ├── user_reset_password │ │ ├── edit.html.heex │ │ └── new.html.heex │ ├── user_settings │ │ └── edit.html.heex │ └── user_setup │ │ └── edit.html.heex │ └── views │ ├── email_view.ex │ ├── error_helpers.ex │ ├── error_view.ex │ ├── helpers.ex │ ├── layout_view.ex │ ├── pdf_view.ex │ ├── user_change_password_view.ex │ ├── user_confirmation_view.ex │ ├── user_reset_password_view.ex │ └── user_setup_view.ex ├── linux.yml ├── mix.exs ├── mix.lock ├── priv ├── fake │ ├── masters.txt │ ├── organizations.json │ └── students.txt ├── gettext │ ├── en │ │ └── LC_MESSAGES │ │ │ └── errors.po │ └── errors.pot ├── repo │ ├── migrations │ │ ├── .formatter.exs │ │ ├── 2022000000000_create_organizations.exs │ │ ├── 20221000000000_create_departments.exs │ │ ├── 20221014155230_create_users_auth_tables.exs │ │ ├── 20221022010100_create_activities.exs │ │ ├── 20221104160002_create_enrollments.exs │ │ ├── 20221123000537_create_partners.exs │ │ ├── 20230313102641_create_memberships.exs │ │ ├── 20230325151547_create_courses.exs │ │ ├── 20230830011755_create_announcements.exs │ │ ├── 20230880102641_create_collaborators.exs │ │ └── 20231111204142_create_posts.exs │ ├── seeds.exs │ └── seeds │ │ ├── accounts.exs │ │ ├── courses.exs │ │ ├── departments.exs │ │ ├── enrollments.exs │ │ ├── feed.exs │ │ ├── memberships.exs │ │ ├── organizations.exs │ │ └── partners.exs └── static │ ├── favicon.ico │ ├── humans.txt │ ├── images │ ├── atomic.svg │ ├── atomic_background.svg │ ├── backgrounds │ │ └── 0.png │ ├── cesium-ORANGE.svg │ ├── facebook.svg │ ├── instagram.svg │ ├── pitch │ │ ├── 0.png │ │ └── 1.png │ ├── tiktok.svg │ ├── x.svg │ └── youtube.svg │ └── robots.txt ├── scripts ├── LICENSE.txt ├── colors.sh ├── execs.sh ├── formatting.sh ├── git.sh ├── helpers.sh ├── logging.sh └── utils.sh ├── storybook ├── _root.index.exs ├── begin.story.exs └── components │ ├── _components.index.exs │ ├── avatar.story.exs │ ├── avatar_group.story.exs │ ├── badge.story.exs │ ├── button.story.exs │ ├── dropdown.story.exs │ ├── empty.story.exs │ ├── forms.story.exs │ ├── gradient.story.exs │ ├── icon.story.exs │ ├── map.story.exs │ ├── spinner.story.exs │ ├── tabs.story.exs │ └── unauthenticated.story.exs └── test ├── atomic ├── accounts_test.exs ├── activities_test.exs ├── departments_test.exs ├── feed_test.exs ├── organizations_test.exs └── partnerships_test.exs ├── atomic_web ├── controllers │ ├── page_controller_test.exs │ ├── user_auth_test.exs │ ├── user_change_password_controller_test.exs │ ├── user_confirmation_controller_test.exs │ ├── user_registration_controller_test.exs │ ├── user_reset_password_controller_test.exs │ └── user_session_controller_test.exs ├── helpers_test.exs └── views │ ├── error_view_test.exs │ ├── layout_view_test.exs │ └── page_view_test.exs ├── support ├── channel_case.ex ├── conn_case.ex ├── data_case.ex ├── factories │ ├── accounts_factory.ex │ ├── activities_factory.ex │ ├── departments_factory.ex │ ├── feed_factory.ex │ └── organizations_factory.ex ├── factory.ex └── fixtures │ ├── accounts_fixtures.ex │ ├── activities_fixtures.ex │ ├── feed_fixtures.ex │ ├── organizations_fixtures.ex │ └── partnerships_fixtures.ex └── test_helper.exs /.env.dev.sample: -------------------------------------------------------------------------------- 1 | DB_USERNAME=postgres 2 | DB_PASSWORD=postgres 3 | DB_HOST=localhost 4 | DB_PORT=5432 5 | DB_NAME=atomic_dev 6 | HOST_URL=http://localhost:4000 7 | ASSET_HOST=http://localhost:4000 8 | FRONTEND_URL=http://localhost:4000 -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :phoenix], 3 | plugins: [TailwindFormatter, DoctestFormatter, Phoenix.LiveView.HTMLFormatter], 4 | heex_line_length: 300, 5 | inputs: [ 6 | "*.{heex,ex,exs}", 7 | "priv/*/seeds.exs", 8 | "priv/repo/seeds/*.exs", 9 | "{config,lib,test}/**/*.{heex,ex,exs}", 10 | "storybook/**/*.exs" 11 | ], 12 | subdirectories: ["priv/*/migrations"] 13 | ] 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/0-feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this project. 3 | labels: ["enhancement"] 4 | body: 5 | - type: checkboxes 6 | attributes: 7 | label: Check for existing issues 8 | description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` (👍) on it. 9 | options: 10 | - label: Completed 11 | required: true 12 | - type: textarea 13 | attributes: 14 | label: Describe the feature 15 | description: A clear and concise description of what you want to happen. 16 | validations: 17 | required: true 18 | - type: textarea 19 | attributes: 20 | label: | 21 | If applicable, add mockups / screenshots to help present your vision of the feature 22 | description: Drag images into the text input below. 23 | validations: 24 | required: false 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Create a bug report to help us improve. 3 | labels: ["bug"] 4 | body: 5 | - type: checkboxes 6 | attributes: 7 | label: Check for existing issues 8 | description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` (👍) on it. 9 | options: 10 | - label: Completed 11 | required: true 12 | - type: textarea 13 | attributes: 14 | label: Describe the bug / provide steps to reproduce it 15 | description: A clear and concise description of what the bug is. 16 | validations: 17 | required: true 18 | - type: dropdown 19 | id: browsers 20 | attributes: 21 | label: What browsers are you seeing the problem on? 22 | multiple: true 23 | options: 24 | - Firefox 25 | - Chrome 26 | - Safari 27 | - Microsoft Edge 28 | - type: textarea 29 | attributes: 30 | label: If applicable, add screenshots to help explain present your vision of the bug 31 | description: Drag issues into the text input below. 32 | validations: 33 | required: false 34 | -------------------------------------------------------------------------------- /.github/brand/atomic-DARK.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/brand/atomic-LIGHT.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/brand/atomic.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cesium/atomic/3456c7fce5cca238cc837676fc840aafcfd06089/.github/brand/atomic.ai -------------------------------------------------------------------------------- /.github/brand/logo-BLACK.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | CeSIUM 10 | 14 | 18 | 22 | 26 | 30 | 34 | 39 | 43 | 47 | 48 | -------------------------------------------------------------------------------- /.github/brand/logo-ORANGE.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | CeSIUM 10 | 14 | 18 | 22 | 26 | 30 | 34 | 39 | 43 | 47 | 48 | -------------------------------------------------------------------------------- /.github/brand/logo-WHITE.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | CeSIUM 10 | 14 | 18 | 22 | 26 | 30 | 34 | 39 | 43 | 47 | 48 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | _Provide a detailed description of the purpose of the changes included in this pull request. Optionally, include background information, relevant screenshots, and any other context that helps explain the work._ 3 | 4 | ## Related Issues 5 | _If applicable, specify the main parts of the application that will be impacted by this pull request._ 6 | 7 | ## Steps to reproduce or test 8 | _Describe the steps that you did to reproduce this._ -------------------------------------------------------------------------------- /.github/workflows/style.yml: -------------------------------------------------------------------------------- 1 | name: Code Quality 2 | 3 | on: 4 | pull_request: 5 | branches: [main, develop] 6 | types: [opened, synchronize] 7 | paths: 8 | - '**/*.ex' 9 | - '**/*.exs' 10 | - '**/*.html.heex' 11 | 12 | jobs: 13 | style: 14 | runs-on: ubuntu-latest 15 | name: Code Quality 16 | 17 | strategy: 18 | matrix: 19 | otp: [27.x] 20 | elixir: [1.17.x] 21 | 22 | steps: 23 | - name: ☁️ Checkout repository 24 | uses: actions/checkout@v3 25 | 26 | - name: 💧 Setup Elixir ${{ matrix.elixir }} (OTP ${{matrix.otp}}) 27 | uses: ./.github/actions 28 | with: 29 | otp-version: ${{ matrix.otp }} 30 | elixir-version: ${{ matrix.elixir }} 31 | build-flags: --all-warnings --warnings-as-errors 32 | 33 | - name: 🎨 Check code formatting 34 | run: mix format --check-formatted 35 | 36 | - name: 🔍 Lint the code 37 | run: mix credo --all --strict -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | push: 5 | branches: [develop] 6 | paths: 7 | - '**/*.ex' 8 | - '**/*.exs' 9 | - '**/*.html.heex' 10 | pull_request: 11 | branches: [develop] 12 | types: [opened, synchronize] 13 | paths: 14 | - '**/*.ex' 15 | - '**/*.exs' 16 | - '**/*.html.heex' 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 21 | env: 22 | MIX_ENV: test 23 | 24 | strategy: 25 | matrix: 26 | otp: [27.x] 27 | elixir: [1.17.x] 28 | 29 | services: 30 | db: 31 | image: postgres:14.1 32 | ports: 33 | - 5432:5432 34 | env: 35 | POSTGRES_USERNAME: postgres 36 | POSTGRES_PASSWORD: postgres 37 | POSTGRES_HOSTNAME: 0.0.0.0 38 | 39 | steps: 40 | - name: ☁️ Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | - name: 💧 Setup Elixir ${{ matrix.elixir }} (OTP ${{matrix.otp}}) 44 | uses: ./.github/actions 45 | with: 46 | otp-version: ${{ matrix.otp }} 47 | elixir-version: ${{ matrix.elixir }} 48 | build-flags: --all-warnings --warnings-as-errors 49 | 50 | - name: ⚙️ Install wkhtmltopdf 51 | run: sudo apt-get update && sudo apt-get install -y wkhtmltopdf 52 | 53 | - name: 🔬 Run the tests 54 | run: mix test --warnings-as-errors 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .env.* 3 | !.env.*.sample 4 | 5 | # The directory Mix will write compiled artifacts to. 6 | /_build/ 7 | 8 | # If you run "mix test --cover", coverage assets end up here. 9 | /cover/ 10 | 11 | # The directory Mix downloads your dependencies sources to. 12 | /deps/ 13 | 14 | # Where 3rd-party dependencies like ExDoc output generated docs. 15 | /doc/ 16 | 17 | # Ignore .fetch files in case you like to edit your project deps locally. 18 | /.fetch 19 | 20 | # If the VM crashes, it generates a dump, let's ignore it too. 21 | erl_crash.dump 22 | 23 | # Also ignore archive artifacts (built via "mix archive.build"). 24 | *.ez 25 | 26 | # Ignore package tarball (built via "mix hex.build"). 27 | store-*.tar 28 | 29 | # Ignore assets that are produced by build tools. 30 | /priv/static/assets/ 31 | 32 | # Ignore digested assets cache. 33 | /priv/static/cache_manifest.json 34 | 35 | # In case you use Node.js/npm, you want to ignore these. 36 | npm-debug.log 37 | /assets/node_modules/ 38 | 39 | # Ignore uploads 40 | /priv/uploads 41 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.17.2-otp-27 2 | erlang 27.0 3 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM surnet/alpine-wkhtmltopdf:3.19.0-0.12.6-full as wkhtmltopdf 2 | FROM elixir:1.14-alpine 3 | 4 | # Install build dependencies 5 | RUN apk add --no-cache build-base git python3 6 | 7 | # Install frontend dependencies 8 | RUN apk add --no-cache nodejs npm 9 | RUN npm install -g npx 10 | 11 | # Install pdf generation dependencies 12 | RUN apk add --no-cache \ 13 | libstdc++ \ 14 | libx11 \ 15 | libxrender \ 16 | libxext \ 17 | libssl3 \ 18 | ca-certificates \ 19 | fontconfig \ 20 | freetype \ 21 | ttf-droid \ 22 | ttf-freefont \ 23 | ttf-liberation \ 24 | # more fonts 25 | ; 26 | 27 | # wkhtmltopdf copy bins from ext image 28 | COPY --from=wkhtmltopdf /bin/wkhtmltopdf /bin/wkhtmltopdf 29 | COPY --from=wkhtmltopdf /bin/wkhtmltoimage /bin/wkhtmltoimage 30 | COPY --from=wkhtmltopdf /bin/libwkhtmltox* /bin/ 31 | 32 | # Install imagemagick dependencies 33 | RUN apk add --no-cache file imagemagick 34 | 35 | # Install development dependencies 36 | RUN apk add --no-cache inotify-tools 37 | 38 | # Install hex + rebar 39 | RUN mix local.hex --force && \ 40 | mix local.rebar --force 41 | 42 | WORKDIR /app 43 | 44 | CMD [ "sh", "-c", "mix setup; mix phx.server" ] 45 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 CeSIUM - Centro de Estudantes de Eng. Informática 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [contributing]: CONTRIBUTING.md 2 | [code_of_conduct]: CODE_OF_CONDUCT.md 3 | [license]: LICENSE.txt 4 | [ci-style-badge]: https://github.com/cesium/atomic/actions/workflows/style.yml/badge.svg 5 | [ci-style-link]: https://github.com/cesium/atomic/actions/workflows/style.yml 6 | [ci-test-badge]: https://github.com/cesium/atomic/actions/workflows/test.yml/badge.svg 7 | [ci-test-link]: https://github.com/cesium/atomic/actions/workflows/test.yml 8 | 9 | # Atomic 10 | 11 | > :atom_symbol: De-engineered bifurcated intranet 12 | 13 | [![Style CI][ci-style-badge]][ci-style-link] 14 | [![Test CI][ci-test-badge]][ci-test-link] 15 | 16 | ## 🤝 Contributing 17 | 18 | When contributing to this repository, please first discuss the change you wish to make via issue, email, or any other method with the owners of this repository before making a change. 19 | 20 | Please note we have a [code of conduct][code_of_conduct], please follow it in all your interactions with the project. 21 | 22 | We have a [contributing guide][contributing] to help you get started. 23 | 24 | ## 📝 License 25 | 26 | 27 | 28 | 29 | Copyright (c) 2023 CeSIUM - Centro de Estudantes de Eng. Informática 30 | 31 | This project is licensed under the MIT License - see the [LICENSE][license] 32 | file for details. 33 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "./components.css"; 3 | @import "tailwindcss/components"; 4 | @import "tailwindcss/utilities"; 5 | 6 | /* Hide password reveal button in MS Edge */ 7 | ::-ms-reveal { 8 | display: none; 9 | } 10 | 11 | /* Hide scrollbar in Webkit-based browsers (Chrome, Safari and Opera) */ 12 | .scrollbar-hide::-webkit-scrollbar { 13 | display: none; 14 | } 15 | 16 | /* Hide scrollbar in IE, MS Edge and Firefox */ 17 | .scrollbar-hide { 18 | -ms-overflow-style: none; /* IE and MS Edge */ 19 | scrollbar-width: none; /* Firefox */ 20 | } 21 | 22 | /* Disable autofill background on inputs */ 23 | input:-webkit-autofill, 24 | input:-webkit-autofill:hover, 25 | input:-webkit-autofill:focus, 26 | input:-webkit-autofill:active { 27 | -webkit-box-shadow: 0 0 0 30px white inset !important; 28 | } 29 | -------------------------------------------------------------------------------- /assets/css/components.css: -------------------------------------------------------------------------------- 1 | @import "components/avatar.css"; 2 | @import "components/badge.css"; 3 | @import "components/button.css"; 4 | @import "components/field.css"; 5 | @import "components/spinner.css"; 6 | -------------------------------------------------------------------------------- /assets/css/components/avatar.css: -------------------------------------------------------------------------------- 1 | /* Avatar */ 2 | 3 | .atomic-avatar { 4 | @apply font-medium leading-none flex shrink-0 items-center justify-center select-none; 5 | } 6 | 7 | /* Avatar - variants */ 8 | 9 | .atomic-avatar--user { 10 | @apply rounded-full; 11 | } 12 | 13 | .atomic-avatar--organization { 14 | @apply rounded-lg; 15 | } 16 | 17 | .atomic-avatar--company { 18 | @apply rounded-lg; 19 | } 20 | 21 | /* Avatar - sizes */ 22 | 23 | .atomic-avatar--xs { 24 | @apply size-8 text-xs; 25 | } 26 | 27 | .atomic-avatar--sm { 28 | @apply size-12 text-lg; 29 | } 30 | 31 | .atomic-avatar--md { 32 | @apply size-16 text-lg; 33 | } 34 | 35 | .atomic-avatar--lg { 36 | @apply size-20 text-4xl; 37 | } 38 | 39 | .atomic-avatar--xl { 40 | @apply size-24 text-4xl; 41 | } 42 | 43 | /* Avatar - colors */ 44 | 45 | .atomic-avatar--primary { 46 | @apply bg-primary-600 text-white; 47 | } 48 | 49 | .atomic-avatar--secondary { 50 | @apply bg-secondary-600 text-white; 51 | } 52 | 53 | .atomic-avatar--white { 54 | @apply bg-white text-zinc-700 border-zinc-300 border; 55 | } 56 | 57 | .atomic-avatar--full_white { 58 | @apply bg-white text-zinc-700; 59 | } 60 | 61 | .atomic-avatar--info { 62 | @apply bg-info-600 text-white; 63 | } 64 | 65 | .atomic-avatar--success { 66 | @apply bg-success-600 text-white; 67 | } 68 | 69 | .atomic-avatar--warning { 70 | @apply bg-warning-600 text-white; 71 | } 72 | 73 | .atomic-avatar--danger { 74 | @apply bg-danger-600 text-white; 75 | } 76 | 77 | .atomic-avatar--zinc { 78 | @apply bg-zinc-600 text-white; 79 | } 80 | 81 | .atomic-avatar--light_zinc { 82 | @apply bg-zinc-400 text-white; 83 | } 84 | 85 | .atomic-avatar--light { 86 | @apply bg-white text-zinc-900 border-zinc-300 dark:bg-zinc-800 dark:text-white dark:border-zinc-600 border; 87 | } 88 | 89 | .atomic-avatar--dark { 90 | @apply bg-zinc-950 text-white border-transparent dark:text-zinc-950 dark:bg-white; 91 | } 92 | 93 | /* Avatar - With image */ 94 | 95 | .atomic-avatar--src { 96 | @apply bg-transparent; 97 | } 98 | 99 | /* Avatar Group */ 100 | 101 | .atomic-avatar-grouped { 102 | @apply ring-1 ring-white; 103 | } -------------------------------------------------------------------------------- /assets/css/components/spinner.css: -------------------------------------------------------------------------------- 1 | /* Spinner */ 2 | 3 | .atomic-spinner { 4 | @apply animate-spin; 5 | } 6 | 7 | /* Spinner - sizes */ 8 | 9 | .atomic-spinner--xs { 10 | @apply w-2 h-2 shrink-0; 11 | } 12 | .atomic-spinner--sm { 13 | @apply w-5 h-5 shrink-0; 14 | } 15 | .atomic-spinner--md { 16 | @apply w-8 h-8 shrink-0; 17 | } 18 | .atomic-spinner--lg { 19 | @apply w-16 h-16 shrink-0; 20 | } 21 | .atomic-spinner--xl { 22 | @apply w-24 h-24 shrink-0; 23 | } 24 | -------------------------------------------------------------------------------- /assets/css/storybook.css: -------------------------------------------------------------------------------- 1 | /* This is your custom storybook stylesheet. */ 2 | @import "tailwindcss/base"; 3 | @import "./components.css"; 4 | @import "tailwindcss/components"; 5 | @import "tailwindcss/utilities"; 6 | 7 | /* 8 | * Put your component styling within the Tailwind utilities layer. 9 | * See the https://hexdocs.pm/phoenix_storybook/sandboxing.html guide for more info. 10 | */ 11 | @layer utilities { 12 | * { 13 | font-family: ui-sans-serif, system-ui; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/js/hooks/index.js: -------------------------------------------------------------------------------- 1 | export { QrScanner } from "./qr_reading.js"; 2 | export { InitSorting } from "./sorting.js"; 3 | export { StickyScroll } from "./sticky_scroll.js"; 4 | export { ScrollToTop } from "./scroll_to_top.js"; -------------------------------------------------------------------------------- /assets/js/hooks/qr_reading.js: -------------------------------------------------------------------------------- 1 | import { Html5Qrcode, Html5QrcodeSupportedFormats } from "../../vendor/html5-qrcode.js" 2 | 3 | function parseURL(url) { 4 | try { 5 | const url_obj = new URL(url); 6 | 7 | if (url_obj.host !== window.location.host) return null; 8 | return url_obj.pathname.split("/").splice(1).join("/"); 9 | } catch { 10 | return null; 11 | } 12 | } 13 | 14 | export const QrScanner = { 15 | 16 | mounted() { 17 | const config = { fps: 4, qrbox: (width, height) => { return { width: width * 0.8, height: height * 0.9 } } }; 18 | this.scanner = new Html5Qrcode(this.el.id, { formatsToSupport: [Html5QrcodeSupportedFormats.QR_CODE] }); 19 | this.isScanning = false; 20 | 21 | const onScanSuccess = (decodedText, decodedResult) => { 22 | const pathname = parseURL(decodedText); 23 | if (pathname != null && pathname !== this.lastRead) { 24 | this.lastRead = pathname; 25 | if (this.el.dataset.on_success) 26 | Function("hook", "pathname", this.el.dataset.on_success)(this, pathname); 27 | } 28 | } 29 | 30 | const startScanner = () => { 31 | this.scanner.start({ facingMode: "environment" }, config, onScanSuccess) 32 | .then((_) => { 33 | this.isScanning = true; 34 | if (this.el.dataset.on_start) 35 | Function("hook", this.el.dataset.on_start)(this); 36 | }) 37 | .catch((e) => { 38 | this.isScanning = false; 39 | if (this.el.dataset.on_error) 40 | Function("hook", this.el.dataset.on_error)(this); 41 | }); 42 | } 43 | 44 | if (this.el.dataset.ask_perm) { 45 | document.getElementById(this.el.dataset.ask_perm).addEventListener("click", startScanner); 46 | } 47 | 48 | if (this.el.dataset.open_on_mount !== undefined) 49 | startScanner(); 50 | }, 51 | 52 | destroyed() { 53 | if (this.isScanning) { 54 | this.scanner.stop().then((_) => { 55 | if (this.el.dataset.on_stop) 56 | Function("hook", this.el.dataset.on_stop)(this); 57 | }).catch((e) => { 58 | console.warn("Failed to stop the scanner:", e); 59 | }); 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /assets/js/hooks/scroll_to_top.js: -------------------------------------------------------------------------------- 1 | export const ScrollToTop = { 2 | mounted() { 3 | this.el.addEventListener("click", e => { 4 | e.preventDefault() 5 | window.scrollTo({ 6 | top: 0, 7 | behavior: 'smooth' 8 | }); 9 | }) 10 | } 11 | } -------------------------------------------------------------------------------- /assets/js/hooks/sorting.js: -------------------------------------------------------------------------------- 1 | import Sortable from "../../vendor/sortable.js" 2 | 3 | export const InitSorting = { 4 | mounted() { 5 | new Sortable(this.el, { 6 | animation: 150, 7 | ghostClass: "bg-slate-100", 8 | dragClass: "shadow-2xl", 9 | handle: ".handle", 10 | onEnd: (evt) => { 11 | const elements = Array.from(this.el.children) 12 | const ids = elements.map(elm => elm.id) 13 | this.pushEvent("update-sorting", {ids: ids}) 14 | } 15 | }) 16 | } 17 | } -------------------------------------------------------------------------------- /assets/js/hooks/sticky_scroll.js: -------------------------------------------------------------------------------- 1 | export const StickyScroll = { 2 | mounted() { 3 | window.addEventListener("scroll",() => { 4 | const panel = document.getElementById("scroll-panel"); 5 | if(panel == null) { window.removeEventListener("scroll", this); return; } 6 | if(window.innerHeight > panel.offsetHeight) return; 7 | panel.style.top = -Math.min(Math.max(window.scrollY, 0), panel.offsetHeight - window.innerHeight) + "px"; 8 | }); 9 | } 10 | } -------------------------------------------------------------------------------- /assets/js/storybook.js: -------------------------------------------------------------------------------- 1 | // If your components require any hooks or custom uploaders, or if your pages 2 | // require connect parameters, uncomment the following lines and declare them as 3 | // such: 4 | // 5 | // import * as Hooks from "./hooks"; 6 | // import * as Params from "./params"; 7 | // import * as Uploaders from "./uploaders"; 8 | 9 | // (function () { 10 | // window.storybook = { Hooks, Params, Uploaders }; 11 | // })(); 12 | 13 | import "../vendor/alpine.js"; 14 | 15 | (function () { 16 | window.storybook = { 17 | LiveSocketOptions: { 18 | dom: { 19 | onBeforeElUpdated(from, to) { 20 | if (from._x_dataStack) { 21 | window.Alpine.clone(from, to) 22 | } 23 | } 24 | } 25 | } 26 | }; 27 | })(); 28 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -Eeuo pipefail 4 | 5 | BASE_DIR=$(dirname "${BASH_SOURCE[0]:-$0}") 6 | cd "${BASE_DIR}/.." || exit 127 7 | 8 | # shellcheck source=../scripts/helpers.sh 9 | . scripts/helpers.sh 10 | # shellcheck source=../scripts/logging.sh 11 | . scripts/logging.sh 12 | # shellcheck source=../scripts/utils.sh 13 | . scripts/utils.sh 14 | 15 | PROGRAM=$(basename "${BASH_SOURCE[0]:-$0}") 16 | VERSION=0.5.4 17 | 18 | function display_help() { 19 | cat < 22 | 23 | $(help_title_section Commands) 24 | docker Open the iex shell inside docker [default command]. 25 | local Open the iex shell. 26 | 27 | $(help_title_section Options) 28 | -h --help Show this screen. 29 | -v --version Show version. 30 | EOF 31 | } 32 | 33 | case ${1:-docker} in 34 | -h | --help) 35 | display_help 36 | ;; 37 | -v | --version) 38 | display_version "${VERSION}" "${PROGRAM}" 39 | ;; 40 | docker) 41 | docker-compose -f docker-compose.dev.yml exec web iex -S mix 42 | ;; 43 | local) 44 | iex -S mix 45 | ;; 46 | *) 47 | display_help >&2 48 | exit 1 49 | ;; 50 | esac 51 | -------------------------------------------------------------------------------- /bin/format: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | mix format 4 | -------------------------------------------------------------------------------- /bin/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | mix credo --strict --all 4 | -------------------------------------------------------------------------------- /bin/pre-commit.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # pre-commit hook script 3 | 4 | set -e 5 | 6 | echo 'Running pipeline...' 7 | 8 | mix ci 9 | 10 | echo 'Success running pipeline!' 11 | -------------------------------------------------------------------------------- /bin/server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -Eeuo pipefail 4 | 5 | BASE_DIR=$(dirname "${BASH_SOURCE[0]:-$0}") 6 | cd "${BASE_DIR}/.." || exit 127 7 | 8 | # shellcheck source=../scripts/helpers.sh 9 | . scripts/helpers.sh 10 | # shellcheck source=../scripts/logging.sh 11 | . scripts/logging.sh 12 | # shellcheck source=../scripts/utils.sh 13 | . scripts/utils.sh 14 | 15 | PROGRAM=$(basename "${BASH_SOURCE[0]:-$0}") 16 | VERSION=0.5.4 17 | 18 | function display_help() { 19 | cat < 22 | 23 | $(help_title_section Commands) 24 | local Start development server in your own machine [default command]. 25 | docker Start development server from a docker container. 26 | 27 | $(help_title_section Options) 28 | -h --help Show this screen. 29 | -v --version Show version. 30 | EOF 31 | } 32 | 33 | case ${1:-local} in 34 | -h | --help) 35 | display_help 36 | ;; 37 | -v | --version) 38 | display_version "${VERSION}" "${PROGRAM}" 39 | ;; 40 | docker) 41 | docker-compose -f docker-compose.dev.yml up -d 42 | ;; 43 | local) 44 | mix phx.server 45 | ;; 46 | *) 47 | display_help >&2 48 | exit 1 49 | ;; 50 | esac 51 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -Eeuo pipefail 4 | 5 | BASE_DIR=$(dirname "${BASH_SOURCE[0]:-$0}") 6 | cd "${BASE_DIR}/.." || exit 127 7 | 8 | # shellcheck source=../scripts/helpers.sh 9 | . scripts/helpers.sh 10 | # shellcheck source=../scripts/logging.sh 11 | . scripts/logging.sh 12 | # shellcheck source=../scripts/utils.sh 13 | . scripts/utils.sh 14 | 15 | PROGRAM=$(basename "${BASH_SOURCE[0]:-$0}") 16 | VERSION=0.5.4 17 | 18 | function display_help() { 19 | cat < 22 | 23 | $(help_title_section Environments) [default: test] 24 | --test Run all tests in test environment. 25 | --dev Run all tests in dev environment. 26 | 27 | $(help_title_section Options) 28 | -h --help Show this screen. 29 | -v --version Show version. 30 | EOF 31 | } 32 | 33 | case ${1:---test} in 34 | -h | --help) 35 | display_help 36 | ;; 37 | -v | --version) 38 | display_version "${VERSION}" "${PROGRAM}" 39 | ;; 40 | --test) 41 | mix test 42 | ;; 43 | *) 44 | display_help >&2 45 | exit 1 46 | ;; 47 | esac 48 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For production, don't forget to configure the url host 4 | # to something meaningful, Phoenix uses this information 5 | # when generating URLs. 6 | # 7 | # Note we also include the path to a cache manifest 8 | # containing the digested version of static files. This 9 | # manifest is generated by the `mix phx.digest` task, 10 | # which you should run after static files are built and 11 | # before starting your production server. 12 | config :atomic, AtomicWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" 13 | 14 | # Do not print debug messages in production 15 | config :logger, level: :info 16 | 17 | # ## SSL Support 18 | # 19 | # To get SSL working, you will need to add the `https` key 20 | # to the previous section and set your `:url` port to 443: 21 | # 22 | # config :atomic, AtomicWeb.Endpoint, 23 | # ..., 24 | # url: [host: "example.com", port: 443], 25 | # https: [ 26 | # ..., 27 | # port: 443, 28 | # cipher_suite: :strong, 29 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 30 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 31 | # ] 32 | # 33 | # The `cipher_suite` is set to `:strong` to support only the 34 | # latest and more secure SSL ciphers. This means old browsers 35 | # and clients may not be supported. You can set it to 36 | # `:compatible` for wider support. 37 | # 38 | # `:keyfile` and `:certfile` expect an absolute path to the key 39 | # and cert in disk or a relative path inside priv, for example 40 | # "priv/ssl/server.key". For all supported SSL configuration 41 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 42 | # 43 | # We also recommend setting `force_ssl` in your endpoint, ensuring 44 | # no data is ever sent via http, always redirecting to https: 45 | # 46 | # config :atomic, AtomicWeb.Endpoint, 47 | # force_ssl: [hsts: true] 48 | # 49 | # Check `Plug.SSL` for all available options in `force_ssl`. 50 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Only in tests, remove the complexity from the password hashing algorithm 4 | config :bcrypt_elixir, :log_rounds, 1 5 | 6 | # Configure your database 7 | # 8 | # The MIX_TEST_PARTITION environment variable can be used 9 | # to provide built-in test partitioning in CI environment. 10 | # Run `mix help test` for more information. 11 | config :atomic, Atomic.Repo, 12 | username: "postgres", 13 | password: "postgres", 14 | hostname: "localhost", 15 | database: "atomic_test#{System.get_env("MIX_TEST_PARTITION")}", 16 | pool: Ecto.Adapters.SQL.Sandbox, 17 | pool_size: 10 18 | 19 | # We don't run a server during test. If one is required, 20 | # you can enable the server option below. 21 | config :atomic, AtomicWeb.Endpoint, 22 | http: [ip: {127, 0, 0, 1}, port: 4002], 23 | secret_key_base: "r9M5TJmKSjEn4aRObrwewuqLRaMDW/J58cZTKs5ZpB+dHTyMb7jf7cg1eRXJ+73v", 24 | server: false 25 | 26 | # In test we don't send emails. 27 | config :atomic, Atomic.Mailer, adapter: Swoosh.Adapters.Test 28 | 29 | # Print only warnings and errors during test 30 | config :logger, level: :warn 31 | 32 | # Initialize plugs at runtime for faster test compilation 33 | config :phoenix, :plug_init_mode, :runtime 34 | 35 | # Other configurations for the app 36 | config :pdf_generator, raise_on_missing_wkhtmltopdf_binary: false 37 | -------------------------------------------------------------------------------- /darwin.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | ports: 4 | - ${DB_PORT:-5555}:5432 5 | web: 6 | ports: 7 | - ${PORT:-4000}:4000 8 | 9 | -------------------------------------------------------------------------------- /data/courses.txt: -------------------------------------------------------------------------------- 1 | Administração Pública 2 | Arqueologia 3 | Artes Visuais 4 | Biologia Aplicada 5 | Biologia e Geologia 6 | Bioquímica 7 | Ciência de Dados 8 | Ciências da Educação 9 | Ciência Política 10 | Ciências da Computação 11 | Ciências da Comunicação 12 | Ciências do Ambiente 13 | Contabilidade 14 | Criminologia e Justiça Criminal 15 | Design de Produto 16 | Design e Marketing de Moda 17 | Direito 18 | Economia 19 | Educação 20 | Educação Básica 21 | Enfermagem 22 | Engenharia Aeroespacial 23 | Engenharia Biomédica 24 | Engenharia Civil 25 | Engenharia de Materiais 26 | Engenharia de Polímeros 27 | Engenharia de Telecomunicações e Informática 28 | Engenharia e Gestão de Sistemas de Informação 29 | Engenharia e Gestão Industrial 30 | Engenharia Eletrónica Industrial e Computadores 31 | Engenharia Física 32 | Engenharia Informática 33 | Engenharia Mecânica 34 | Engenharia Química e Biológica 35 | Engenharia Têxtil 36 | Estatística Aplicada 37 | Estudos Culturais 38 | Estudos Orientais: Estudos Chineses e Japoneses 39 | Estudos Portugueses 40 | Filosofia 41 | Física 42 | Geografia e Planeamento 43 | Geologia 44 | Gestão 45 | História 46 | Línguas Aplicadas 47 | Línguas e Literaturas Europeias 48 | Medicina 49 | Marketing 50 | Matemática 51 | Música 52 | Negócios Internacionais 53 | Optometria e Ciências da Visão 54 | Proteção Civil e Gestão do Território 55 | Psicologia 56 | Química 57 | Relações Internacionais 58 | Sociologia 59 | Teatro 60 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: postgres:14.1 4 | container_name: atomic_db 5 | env_file: .env.dev 6 | environment: 7 | POSTGRES_USER: ${DB_USERNAME:-postgres} 8 | POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} 9 | POSTGRES_HOST: ${DB_HOST:-localhost} 10 | volumes: 11 | - /var/lib/postgresql/data 12 | web: 13 | container_name: atomic_web 14 | env_file: .env.dev 15 | environment: 16 | MIX_ENV: ${MIX_ENV:-dev} 17 | build: 18 | context: . 19 | dockerfile: Dockerfile.dev 20 | depends_on: 21 | - db 22 | volumes: 23 | - ./:/app 24 | - /app/_build 25 | - /app/deps 26 | - /app/priv/uploads 27 | -------------------------------------------------------------------------------- /lib/atomic.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic do 2 | @moduledoc """ 3 | Atomic keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/atomic/accounts/course.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Accounts.Course do 2 | @moduledoc """ 3 | A course the user is enrolled in. 4 | """ 5 | use Atomic.Schema 6 | 7 | alias Atomic.Accounts.User 8 | 9 | @required_fields ~w(name cycle)a 10 | @cycles ~w(Bachelors Masters PhD)a 11 | 12 | schema "courses" do 13 | field :name, :string 14 | field :cycle, Ecto.Enum, values: @cycles 15 | 16 | has_many :users, User 17 | 18 | timestamps() 19 | end 20 | 21 | @doc false 22 | def changeset(course, attrs) do 23 | course 24 | |> cast(attrs, @required_fields) 25 | |> validate_required(@required_fields) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/atomic/accounts/user_notifier.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Accounts.UserNotifier do 2 | @moduledoc false 3 | import Swoosh.Email 4 | 5 | alias Atomic.Mailer 6 | 7 | use Phoenix.Swoosh, view: AtomicWeb.EmailView 8 | 9 | defp base_email(to: email) do 10 | new() 11 | |> to(email) 12 | |> from({"Atomic", "noreply@atomic.cesium.pt"}) 13 | end 14 | 15 | defp deliver(recipient, subject, body) do 16 | email = 17 | new() 18 | |> to(recipient) 19 | |> from({"Atomic", "contact@example.com"}) 20 | |> subject(subject) 21 | |> text_body(body) 22 | 23 | with {:ok, _metadata} <- Mailer.deliver(email) do 24 | {:ok, email} 25 | end 26 | end 27 | 28 | @doc """ 29 | Deliver instructions to confirm account. 30 | """ 31 | def deliver_confirmation_instructions(user, url) do 32 | email = 33 | base_email(to: user.email) 34 | |> subject("Confirm your Account") 35 | |> assign(:user, user) 36 | |> assign(:url, url) 37 | |> render_body("user_confirmation.html") 38 | 39 | case Mailer.deliver(email) do 40 | {:ok, _term} -> {:ok, email} 41 | {:error, ch} -> {:error, ch} 42 | end 43 | end 44 | 45 | @doc """ 46 | Deliver instructions to reset a user password. 47 | """ 48 | def deliver_reset_password_instructions(user, url) do 49 | email = 50 | base_email(to: user.email) 51 | |> subject("Reset Password Instructions") 52 | |> assign(:user, user) 53 | |> assign(:url, url) 54 | |> render_body("user_reset_password.html") 55 | 56 | case Mailer.deliver(email) do 57 | {:ok, _term} -> {:ok, email} 58 | {:error, ch} -> {:error, ch} 59 | end 60 | end 61 | 62 | @doc """ 63 | Deliver instructions to update a user email. 64 | """ 65 | def deliver_update_email_instructions(user, url) do 66 | deliver(user.email, "Update email instructions", """ 67 | 68 | ============================== 69 | 70 | Hi #{user.email}, 71 | 72 | You can change your email by visiting the URL below: 73 | 74 | #{url} 75 | 76 | If you didn't request this change, please ignore this. 77 | 78 | ============================== 79 | """) 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/atomic/activities/enrollment.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Activities.Enrollment do 2 | @moduledoc """ 3 | An activity enrollment. 4 | """ 5 | use Atomic.Schema 6 | 7 | alias Atomic.Accounts.User 8 | alias Atomic.Activities 9 | alias Atomic.Activities.Activity 10 | 11 | @required_fields ~w(activity_id user_id)a 12 | @optional_fields ~w(present)a 13 | 14 | schema "enrollments" do 15 | field :present, :boolean, default: false 16 | 17 | belongs_to :activity, Activity 18 | belongs_to :user, User 19 | 20 | timestamps() 21 | end 22 | 23 | def changeset(enrollment, attrs) do 24 | enrollment 25 | |> cast(attrs, @required_fields ++ @optional_fields) 26 | |> validate_maximum_entries() 27 | |> validate_required(@required_fields) 28 | end 29 | 30 | def update_changeset(enrollment, attrs) do 31 | enrollment 32 | |> cast(attrs, @required_fields ++ @optional_fields) 33 | |> validate_required(@required_fields) 34 | end 35 | 36 | defp validate_maximum_entries(changeset) do 37 | activity_id = get_field(changeset, :activity_id) 38 | activity = Activities.get_activity!(activity_id) 39 | 40 | if activity.maximum_entries <= activity.enrolled do 41 | add_error(changeset, :activity_id, gettext("maximum number of enrollments reached")) 42 | else 43 | changeset 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/atomic/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | children = [ 11 | # Start the Ecto repository 12 | Atomic.Repo, 13 | # Start the Telemetry supervisor 14 | AtomicWeb.Telemetry, 15 | # Start the PubSub system 16 | {Phoenix.PubSub, name: Atomic.PubSub}, 17 | # Start the Endpoint (http/https) 18 | AtomicWeb.Endpoint, 19 | # Start the scheduler 20 | Atomic.Scheduler 21 | # Start a worker by calling: Atomic.Worker.start_link(arg) 22 | # {Atomic.Worker, arg} 23 | ] 24 | 25 | # See https://hexdocs.pm/elixir/Supervisor.html 26 | # for other strategies and supported options 27 | opts = [strategy: :one_for_one, name: Atomic.Supervisor] 28 | Supervisor.start_link(children, opts) 29 | end 30 | 31 | # Tell Phoenix to update the endpoint configuration 32 | # whenever the application is updated. 33 | @impl true 34 | def config_change(changed, _new, removed) do 35 | AtomicWeb.Endpoint.config_change(changed, removed) 36 | :ok 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/atomic/context.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Context do 2 | @moduledoc """ 3 | A utility context providing common functions to all context modules. 4 | """ 5 | defmacro __using__(_) do 6 | quote do 7 | import Ecto.Query, warn: false 8 | 9 | alias Atomic.Repo 10 | alias Ecto.Multi 11 | 12 | def apply_filters(query, opts) do 13 | Enum.reduce(opts, query, fn 14 | {:where, filters}, query -> 15 | where(query, ^filters) 16 | 17 | {:fields, fields}, query -> 18 | select(query, [i], map(i, ^fields)) 19 | 20 | {:order_by, criteria}, query -> 21 | order_by(query, ^criteria) 22 | 23 | {:limit, criteria}, query -> 24 | limit(query, ^criteria) 25 | 26 | {:offset, criteria}, query -> 27 | offset(query, ^criteria) 28 | 29 | {:preloads, preloads}, query when is_list(preloads) -> 30 | Enum.reduce(preloads, query, fn preload, query -> 31 | preload(query, ^preload) 32 | end) 33 | 34 | {:preloads, preload}, query -> 35 | preload(query, ^preload) 36 | 37 | _, query -> 38 | query 39 | end) 40 | end 41 | 42 | defp after_save({:ok, data}, func) do 43 | {:ok, _data} = func.(data) 44 | end 45 | 46 | defp after_save(error, _func), do: error 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/atomic/feed/post.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Feed.Post do 2 | @moduledoc """ 3 | A post published in the feed. Can either be an announcement or an activity. 4 | """ 5 | use Atomic.Schema 6 | 7 | alias Atomic.Activities.Activity 8 | alias Atomic.Organizations.Announcement 9 | 10 | @types ~w(activity announcement)a 11 | 12 | @required_fields ~w(type)a 13 | 14 | schema "posts" do 15 | field :type, Ecto.Enum, values: @types 16 | 17 | has_one :activity, Activity 18 | has_one :announcement, Announcement 19 | 20 | timestamps() 21 | end 22 | 23 | def changeset(post, attrs) do 24 | post 25 | |> cast(attrs, @required_fields) 26 | |> validate_required(@required_fields) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/atomic/generate_avatar.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.GenerateAvatar do 2 | @moduledoc """ 3 | A module for generating unique, GitHub-style avatars for organizations. 4 | """ 5 | 6 | import Phoenix.HTML 7 | 8 | @grid_size 5 9 | @cell_size 50 10 | 11 | def generate_avatar(seed, output_type) do 12 | hash = :crypto.hash(:sha256, seed) |> :binary.bin_to_list() 13 | color = Enum.take(hash, 3) 14 | grid = build_grid(hash) 15 | svg = draw(grid, color) 16 | 17 | handle_output(svg, output_type) 18 | end 19 | 20 | defp handle_output(svg, output) when is_binary(output), do: File.write(output, svg) 21 | 22 | defp handle_output(svg, :svg), do: svg 23 | defp handle_output(svg, :blob), do: :erlang.term_to_binary(svg) 24 | defp handle_output(svg, :html), do: raw(svg) 25 | 26 | defp handle_output(_svg, invalid) do 27 | raise ArgumentError, 28 | "Invalid output type: #{inspect(invalid)}. Expected one of :svg, :blob, :html, or a file path string." 29 | end 30 | 31 | defp build_grid(hash) do 32 | hash 33 | |> Enum.chunk_every(@grid_size, @grid_size, :discard) 34 | |> Enum.map(&mirror/1) 35 | |> List.flatten() 36 | end 37 | 38 | defp mirror([a, b, c | _]), do: [a, b, c, b, a] 39 | 40 | defp draw(grid, [r, g, b]) do 41 | header = """ 42 | 43 | """ 44 | 45 | footer = "" 46 | 47 | body = 48 | Enum.map_join( 49 | grid 50 | |> Enum.with_index() 51 | |> Enum.filter(fn {val, _} -> rem(val, 2) == 0 end), 52 | "\n", 53 | fn {_val, index} -> 54 | x = rem(index, @grid_size) * @cell_size 55 | y = div(index, @grid_size) * @cell_size 56 | 57 | "" 58 | end 59 | ) 60 | 61 | header <> body <> footer 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/atomic/location/location.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Location do 2 | @moduledoc """ 3 | A location embedded struct schema. 4 | """ 5 | use Atomic.Schema 6 | 7 | @required_fields ~w(name)a 8 | @optional_fields ~w(url)a 9 | 10 | @derive Jason.Encoder 11 | @primary_key false 12 | embedded_schema do 13 | field :name, :string 14 | field :url, :string 15 | end 16 | 17 | def changeset(location, attrs) do 18 | location 19 | |> cast(attrs, @required_fields ++ @optional_fields) 20 | |> validate_required(@required_fields) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/atomic/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Mailer do 2 | @moduledoc false 3 | use Swoosh.Mailer, otp_app: :atomic 4 | end 5 | -------------------------------------------------------------------------------- /lib/atomic/organizations/announcement.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Organizations.Announcement do 2 | @moduledoc """ 3 | An announcement created and published by an organization. 4 | """ 5 | use Atomic.Schema 6 | 7 | alias Atomic.Feed.Post 8 | alias Atomic.Organizations.Organization 9 | 10 | @required_fields ~w(title description organization_id)a 11 | @optional_fields ~w()a 12 | 13 | @derive { 14 | Flop.Schema, 15 | filterable: [], 16 | sortable: [:inserted_at], 17 | default_order: %{ 18 | order_by: [:inserted_at], 19 | order_directions: [:desc] 20 | } 21 | } 22 | 23 | schema "announcements" do 24 | field :title, :string 25 | field :description, :string 26 | field :image, Uploaders.Post.Type 27 | 28 | belongs_to :organization, Organization 29 | belongs_to :post, Post, foreign_key: :post_id 30 | 31 | timestamps() 32 | end 33 | 34 | def changeset(announcements, attrs) do 35 | announcements 36 | |> cast(attrs, @required_fields ++ @optional_fields) 37 | |> validate_required(@required_fields) 38 | end 39 | 40 | def image_changeset(announcement, attrs) do 41 | announcement 42 | |> cast_attachments(attrs, [:image]) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/atomic/organizations/collaborator.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Organizations.Collaborator do 2 | @moduledoc """ 3 | A relation representing an organization department collaborator. 4 | """ 5 | use Atomic.Schema 6 | 7 | alias Atomic.Accounts.User 8 | alias Atomic.Organizations.Department 9 | 10 | @required_fields ~w(user_id department_id accepted)a 11 | @optional_fields ~w(accepted_at)a 12 | 13 | @derive { 14 | Flop.Schema, 15 | default_limit: 7, 16 | filterable: [:accepted], 17 | sortable: [:collaborator_name, :inserted_at, :updated_at], 18 | default_order: %{ 19 | order_by: [:inserted_at], 20 | order_directions: [:desc] 21 | }, 22 | adapter_opts: [ 23 | join_fields: [ 24 | collaborator_name: [binding: :user, field: :name, path: [:user, :name]] 25 | ] 26 | ] 27 | } 28 | 29 | schema "collaborators" do 30 | belongs_to :user, User 31 | belongs_to :department, Department 32 | 33 | field :accepted, :boolean, default: false 34 | field :accepted_at, :naive_datetime 35 | 36 | timestamps() 37 | end 38 | 39 | def changeset(collaborator_departments, attrs) do 40 | collaborator_departments 41 | |> cast(attrs, @required_fields ++ @optional_fields) 42 | |> validate_required(@required_fields) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/atomic/organizations/department.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Organizations.Department do 2 | @moduledoc """ 3 | A department of an organization. 4 | """ 5 | use Atomic.Schema 6 | 7 | alias Atomic.Organizations.Organization 8 | 9 | @required_fields ~w(name organization_id)a 10 | @optional_fields ~w(description collaborator_applications archived)a 11 | 12 | schema "departments" do 13 | field :name, :string 14 | field :description, :string 15 | 16 | field :collaborator_applications, :boolean, default: false 17 | field :archived, :boolean, default: false 18 | 19 | field :banner, Atomic.Uploaders.Banner.Type 20 | 21 | belongs_to :organization, Organization, on_replace: :delete_if_exists 22 | 23 | timestamps() 24 | end 25 | 26 | def changeset(department, attrs) do 27 | department 28 | |> cast(attrs, @required_fields ++ @optional_fields) 29 | |> validate_required(@required_fields) 30 | end 31 | 32 | def banner_changeset(department, attrs) do 33 | department 34 | |> cast_attachments(attrs, [:banner]) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/atomic/organizations/membership.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Organizations.Membership do 2 | @moduledoc """ 3 | Schema representing a user's membership in an organization. 4 | 5 | Memberships are used to track the relationship between a user and an organization. 6 | 7 | Types of memberships: 8 | * `owner` - The user has full control over the organization. 9 | * `admin` - The user can control the organization's departments, activities and partners. 10 | * `follower` - The user is following the organization. 11 | 12 | This schema can be further extended to include additional roles, such as `member`. 13 | """ 14 | use Atomic.Schema 15 | 16 | alias Atomic.Accounts.User 17 | alias Atomic.Organizations.Organization 18 | 19 | @required_fields ~w(user_id organization_id role)a 20 | @optional_fields ~w()a 21 | 22 | @roles ~w(follower admin owner)a 23 | 24 | schema "memberships" do 25 | field :role, Ecto.Enum, values: @roles 26 | 27 | belongs_to :user, User 28 | belongs_to :organization, Organization 29 | 30 | timestamps() 31 | end 32 | 33 | def changeset(organization, attrs) do 34 | organization 35 | |> cast(attrs, @required_fields ++ @optional_fields) 36 | |> validate_required(@required_fields) 37 | end 38 | 39 | def roles, do: @roles 40 | end 41 | -------------------------------------------------------------------------------- /lib/atomic/organizations/organization.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Organizations.Organization do 2 | @moduledoc false 3 | use Atomic.Schema 4 | 5 | alias Atomic.Accounts.User 6 | alias Atomic.Location 7 | alias Atomic.Organizations.{Announcement, Department, Membership, Partner} 8 | alias Atomic.Uploaders 9 | 10 | @required_fields ~w(name long_name description)a 11 | @optional_fields ~w()a 12 | 13 | @derive { 14 | Flop.Schema, 15 | filterable: [], 16 | sortable: [:name], 17 | compound_fields: [search: [:name]], 18 | default_order: %{ 19 | order_by: [:name], 20 | order_directions: [:asc] 21 | } 22 | } 23 | 24 | schema "organizations" do 25 | field :name, :string 26 | field :long_name, :string 27 | field :description, :string 28 | 29 | field :logo, Uploaders.Logo.Type 30 | embeds_one :location, Location, on_replace: :delete 31 | 32 | has_many :departments, Department, 33 | on_replace: :delete_if_exists, 34 | on_delete: :delete_all, 35 | preload_order: [asc: :name] 36 | 37 | has_many :partners, Partner, 38 | on_replace: :delete_if_exists, 39 | on_delete: :delete_all, 40 | preload_order: [asc: :name] 41 | 42 | has_many :announcements, Announcement, 43 | on_replace: :delete, 44 | preload_order: [asc: :inserted_at] 45 | 46 | many_to_many :users, User, join_through: Membership 47 | 48 | timestamps() 49 | end 50 | 51 | def changeset(organization, attrs) do 52 | organization 53 | |> cast(attrs, @required_fields ++ @optional_fields) 54 | |> cast_embed(:location, with: &Location.changeset/2) 55 | |> validate_required(@required_fields) 56 | |> unique_constraint(:name) 57 | end 58 | 59 | def logo_changeset(organization, attrs) do 60 | organization 61 | |> cast_attachments(attrs, [:logo]) 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/atomic/organizations/partner.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Organizations.Partner do 2 | @moduledoc """ 3 | Schema representing a partner of an organization. 4 | """ 5 | use Atomic.Schema 6 | 7 | alias Atomic.Location 8 | alias Atomic.Organizations.Organization 9 | alias Atomic.Socials 10 | 11 | @required_fields ~w(name organization_id)a 12 | @optional_fields ~w(description benefits archived image notes)a 13 | 14 | @derive { 15 | Flop.Schema, 16 | filterable: [], 17 | sortable: [:name], 18 | compound_fields: [search: [:name]], 19 | default_order: %{ 20 | order_by: [:name], 21 | order_directions: [:asc] 22 | } 23 | } 24 | 25 | schema "partners" do 26 | field :name, :string 27 | field :description, :string 28 | field :notes, :string 29 | 30 | field :benefits, :string 31 | field :archived, :boolean, default: false 32 | field :image, Uploaders.PartnerImage.Type 33 | 34 | embeds_one :location, Location, on_replace: :update 35 | embeds_one :socials, Socials, on_replace: :update 36 | 37 | belongs_to :organization, Organization 38 | 39 | timestamps() 40 | end 41 | 42 | def changeset(partner, attrs) do 43 | partner 44 | |> cast(attrs, @required_fields ++ @optional_fields) 45 | |> cast_embed(:location, with: &Location.changeset/2) 46 | |> cast_embed(:socials, with: &Socials.changeset/2) 47 | |> validate_required(@required_fields) 48 | |> unique_constraint(:name) 49 | end 50 | 51 | def image_changeset(partner, attrs) do 52 | partner 53 | |> cast_attachments(attrs, [:image]) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/atomic/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Repo do 2 | use Ecto.Repo, 3 | otp_app: :atomic, 4 | adapter: Ecto.Adapters.Postgres 5 | 6 | use Paginator 7 | end 8 | -------------------------------------------------------------------------------- /lib/atomic/scheduler.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Scheduler do 2 | @moduledoc false 3 | use Quantum, otp_app: :atomic 4 | end 5 | -------------------------------------------------------------------------------- /lib/atomic/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Schema do 2 | @moduledoc """ 3 | The application Schema for all the modules, providing Ecto.UUIDs as default id. 4 | """ 5 | use Gettext, backend: AtomicWeb.Gettext 6 | 7 | alias Atomic.Time 8 | 9 | defmacro __using__(_) do 10 | quote do 11 | use Ecto.Schema 12 | use Waffle.Ecto.Schema 13 | use Gettext, backend: AtomicWeb.Gettext 14 | 15 | import Ecto.Changeset 16 | import Ecto.Query 17 | 18 | alias Atomic.Uploaders 19 | 20 | @primary_key {:id, :binary_id, autogenerate: true} 21 | @foreign_key_type :binary_id 22 | 23 | def validate_email_address(changeset, field) do 24 | changeset 25 | |> validate_format( 26 | field, 27 | ~r/^[\w.!#$%&’*+\-\/=?\^`{|}~]+@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/i, 28 | message: gettext("must be a valid email") 29 | ) 30 | end 31 | 32 | def validate_naive_datetime(changeset, field, :future) do 33 | validate_change(changeset, field, fn _field, value -> 34 | if NaiveDateTime.compare(value, Time.lisbon_now()) == :lt do 35 | [{field, gettext("date in the past")}] 36 | else 37 | [] 38 | end 39 | end) 40 | end 41 | 42 | def validate_naive_datetime(changeset, field, date) do 43 | validate_change(changeset, field, fn _field, value -> 44 | if NaiveDateTime.compare(value, date) == :lt do 45 | [{field, gettext("date requires to be after %{date}", date: date)}] 46 | else 47 | [] 48 | end 49 | end) 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/atomic/socials/socials.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Socials do 2 | @moduledoc """ 3 | A socials embedded struct schema. 4 | """ 5 | use Atomic.Schema 6 | 7 | @optional_fields ~w(instagram facebook x youtube tiktok website)a 8 | 9 | @derive Jason.Encoder 10 | @primary_key false 11 | embedded_schema do 12 | field :instagram, :string 13 | field :facebook, :string 14 | field :x, :string 15 | field :youtube, :string 16 | field :tiktok, :string 17 | field :website, :string 18 | end 19 | 20 | def changeset(socials, attrs) do 21 | socials 22 | |> cast(attrs, @optional_fields) 23 | |> validate_format(:website, ~r{^https?://}, message: "must start with http:// or https://") 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/atomic/time.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Time do 2 | @moduledoc """ 3 | This module provide time utilities. 4 | """ 5 | 6 | @timezone "Europe/Lisbon" 7 | 8 | def lisbon_now do 9 | Timex.now() 10 | |> Timex.Timezone.convert(timezone()) 11 | end 12 | 13 | def convert_to_lisbon(datetime) do 14 | Timex.Timezone.convert(datetime, timezone()) 15 | end 16 | 17 | defp timezone do 18 | Timex.Timezone.get(@timezone) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/atomic/uploader.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Uploader do 2 | @moduledoc """ 3 | A utility module providing common functions to all uploaders modules. 4 | Put `use Atomic.Uploader` on top of your uploader module to use it. 5 | """ 6 | 7 | defmacro __using__(opts) do 8 | quote do 9 | use Waffle.Definition 10 | use Waffle.Ecto.Definition 11 | 12 | def validate({file, _}) do 13 | file_extension = file.file_name |> Path.extname() |> String.downcase() 14 | size = file_size(file) 15 | 16 | case Enum.member?(extension_whitelist(), file_extension) do 17 | true -> 18 | if size <= max_size() do 19 | :ok 20 | else 21 | {:error, "file size exceeds maximum allowed size"} 22 | end 23 | 24 | false -> 25 | {:error, "invalid file extension"} 26 | end 27 | end 28 | 29 | def extension_whitelist do 30 | Keyword.get(unquote(opts), :extensions, []) 31 | end 32 | 33 | def max_size do 34 | Keyword.get(unquote(opts), :max_file_size, 100_000_000) 35 | end 36 | 37 | def file_size(%Waffle.File{} = file) do 38 | File.stat!(file.path) |> Map.get(:size) 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/atomic/uploaders/banner.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Uploaders.Banner do 2 | @moduledoc """ 3 | Uploader for user banners. 4 | """ 5 | use Atomic.Uploader, extensions: ~w(.jpg .jpeg .png .gif) 6 | alias Atomic.Accounts.User 7 | 8 | @versions [:original] 9 | 10 | def storage_dir(_version, {_file, %User{} = user}) do 11 | "uploads/atomic/users/#{user.id}/banner" 12 | end 13 | 14 | def filename(version, _) do 15 | version 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/atomic/uploaders/logo.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Uploaders.Logo do 2 | @moduledoc """ 3 | Uploader for organization logos. 4 | """ 5 | use Atomic.Uploader, extensions: ~w(.jpg .jpeg .png .svg) 6 | alias Atomic.Organizations.Organization 7 | 8 | @versions [:original] 9 | 10 | def storage_dir(_version, {_file, %Organization{} = organization}) do 11 | "uploads/atomic/organizations/#{organization.id}/logo" 12 | end 13 | 14 | def filename(version, _) do 15 | version 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/atomic/uploaders/partner_image.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Uploaders.PartnerImage do 2 | @moduledoc """ 3 | Uploader for partner images. 4 | """ 5 | use Atomic.Uploader, extensions: ~w(.jpg .jpeg .png) 6 | 7 | alias Atomic.Organizations.Partner 8 | 9 | @versions [:original] 10 | 11 | def storage_dir(_version, {_file, %Partner{} = partner}) do 12 | "uploads/atomic/partners/#{partner.id}/logo" 13 | end 14 | 15 | def filename(version, _) do 16 | version 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/atomic/uploaders/post.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Uploaders.Post do 2 | @moduledoc """ 3 | Uploader for posts. 4 | """ 5 | use Atomic.Uploader, extensions: ~w(.jpg .jpeg .png .svg) 6 | 7 | alias Atomic.Activities.Activity 8 | alias Atomic.Organizations.Announcement 9 | 10 | @versions [:original] 11 | 12 | def storage_dir(_version, {_file, %Activity{} = activity}) do 13 | "uploads/atomic/activities/#{activity.id}/image" 14 | end 15 | 16 | def storage_dir(_version, {_file, %Announcement{} = announcement}) do 17 | "uploads/atomic/announcements/#{announcement.id}/image" 18 | end 19 | 20 | def filename(version, _) do 21 | version 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/atomic/uploaders/profile_picture.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Uploaders.ProfilePicture do 2 | @moduledoc """ 3 | Uploader for profile pictures. 4 | """ 5 | use Atomic.Uploader, extensions: ~w(.jpg .jpeg .png .gif) 6 | alias Atomic.Accounts.User 7 | 8 | @versions [:original] 9 | 10 | def storage_dir(_version, {_file, %User{} = user}) do 11 | "uploads/atomic/users/#{user.id}/profile_picture" 12 | end 13 | 14 | def filename(version, _) do 15 | version 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/atomic_web/components/announcement.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.Components.Announcement do 2 | @moduledoc """ 3 | Renders an announcement. 4 | """ 5 | use AtomicWeb, :component 6 | 7 | import AtomicWeb.Components.Avatar 8 | 9 | attr :announcement, :map, required: true, doc: "The announcement to render." 10 | 11 | def announcement(assigns) do 12 | ~H""" 13 |
14 |
15 |
16 | <.avatar name={@announcement.organization.name} color={:light_zinc} class="!h-10 !w-10" size={:xs} type={:organization} src={Uploaders.Logo.url({@announcement.organization.logo, @announcement.organization}, :original)} /> 17 |
18 |
19 | 20 | <.link navigate={~p"/organizations/#{@announcement.organization.id}"} class="hover:underline focus:outline-none"> 21 |

22 | {@announcement.organization.name} 23 |

24 | 25 |
26 |

27 | Published on 28 | 29 |

30 |
31 |
32 |

{@announcement.title}

33 |
34 | {maybe_slice_string(@announcement.description, 300)} 35 |
36 | 37 | <%= if @announcement.image do %> 38 |
39 | 40 |
41 | <% end %> 42 |
43 | """ 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/atomic_web/components/badge.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.Components.Badge do 2 | @moduledoc false 3 | use Phoenix.Component 4 | 5 | import AtomicWeb.Components.Icon 6 | 7 | attr :size, :atom, 8 | default: :md, 9 | values: [:xs, :sm, :md, :lg, :xl], 10 | doc: "The size of the badge." 11 | 12 | attr :variant, :atom, 13 | default: :light, 14 | values: [:light, :dark, :outline], 15 | doc: "The variant of the badge." 16 | 17 | attr :color, :atom, 18 | default: :primary, 19 | values: [:primary, :secondary, :info, :success, :warning, :danger, :zinc], 20 | doc: "Badge color." 21 | 22 | attr :icon_position, :atom, 23 | values: [:left, :right], 24 | default: :left, 25 | doc: "The position of the icon if applicable." 26 | 27 | attr :icon, :string, default: nil, doc: "The icon to display." 28 | attr :icon_class, :string, default: "", doc: "Additional classes to apply to the icon." 29 | 30 | attr :class, :string, default: "", doc: "Additional classes to apply to the badge." 31 | attr :label, :string, default: nil, doc: "Badge label." 32 | 33 | attr :rest, :global, 34 | include: 35 | ~w(csrf_token disabled download form href hreflang method name navigate patch referrerpolicy rel replace target type value autofocus tabindex), 36 | doc: "Arbitrary HTML or phx attributes." 37 | 38 | slot :inner_block, required: false, doc: "Slot for the content of the badge." 39 | 40 | def badge(assigns) do 41 | ~H""" 42 |
51 | <%= if @icon && @icon_position == :left do %> 52 | <.icon name={@icon} class={"#{generate_icon_classes(assigns)}"} /> 53 | <% end %> 54 | {render_slot(@inner_block) || @label} 55 | <%= if @icon && @icon_position == :right do %> 56 | <.icon name={@icon} class={"#{generate_icon_classes(assigns)}"} /> 57 | <% end %> 58 |
59 | """ 60 | end 61 | 62 | defp generate_icon_classes(assigns) do 63 | [ 64 | "atomic-button__icon--#{assigns.size}", 65 | assigns.icon_class 66 | ] 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/atomic_web/components/empty.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.Components.Empty do 2 | @moduledoc """ 3 | A component for displaying an empty state. 4 | """ 5 | use AtomicWeb, :component 6 | 7 | alias Inflex 8 | 9 | attr :id, :string, default: "empty-state", required: false 10 | attr :placeholder, :string, required: true 11 | attr :url, :string, required: true 12 | 13 | def empty_state(assigns) do 14 | ~H""" 15 |
16 | <.icon name="hero-plus-circle" class="size-12 mx-auto text-zinc-400" /> 17 |

No {plural(@placeholder)}

18 |

Get started by creating a new {@placeholder}.

19 |
20 | <.link navigate={@url} class="bg-primary-500 inline-flex items-center rounded-md px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-primary-600 focus-visible:outline-primary-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"> 21 | 24 | New {@placeholder} 25 | 26 |
27 |
28 | """ 29 | end 30 | 31 | # Returns the plural form of a word. 32 | @spec plural(String.t()) :: String.t() 33 | defp plural(word), do: Inflex.pluralize(word) 34 | end 35 | -------------------------------------------------------------------------------- /lib/atomic_web/components/gradient.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.Components.Gradient do 2 | @moduledoc """ 3 | Generates a random gradient background or a predictable gradient background based on a seed that can be of any data type. 4 | """ 5 | use Phoenix.Component 6 | 7 | # List of gradients 8 | @colors [ 9 | {"#000046", "#1CB5E0"}, 10 | {"#007991", "#78ffd6"}, 11 | {"#30E8BF", "#FF8235"}, 12 | {"#C33764", "#1D2671"}, 13 | {"#34e89e", "#0f3443"}, 14 | {"#44A08D", "#093637"}, 15 | {"#DCE35B", "#45B649"}, 16 | {"#c0c0aa", "#1cefff"}, 17 | {"#ee0979", "#ff6a00"} 18 | ] 19 | 20 | attr :class, :string, default: "", doc: "Additional classes to apply to the component." 21 | attr :seed, :any, required: false, doc: "For predictable gradients." 22 | 23 | def gradient(assigns) do 24 | {gradient_color_a, gradient_color_b} = 25 | if Map.has_key?(assigns, :seed) do 26 | generate_color(assigns.seed) 27 | else 28 | generate_color() 29 | end 30 | 31 | assigns 32 | |> assign(:gradient_color_a, gradient_color_a) 33 | |> assign(:gradient_color_b, gradient_color_b) 34 | |> render_gradient() 35 | end 36 | 37 | defp render_gradient(assigns) do 38 | ~H""" 39 |
40 | """ 41 | end 42 | 43 | defp generate_color(seed) when is_binary(seed) do 44 | # Convert the argument into an integer 45 | index = :erlang.phash2(seed, length(@colors)) 46 | 47 | # Return the chosen color 48 | Enum.at(@colors, index) 49 | end 50 | 51 | defp generate_color do 52 | Enum.random(@colors) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/atomic_web/components/icon.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.Components.Icon do 2 | @moduledoc """ 3 | A component for rendering icons. 4 | 5 | An icon can either be from the [Heroicons](https://heroicons.com) or [Tabler Icons](https://tablericons.com) set. 6 | """ 7 | use Phoenix.Component 8 | 9 | attr :name, :string, required: true 10 | attr :class, :string, default: nil 11 | 12 | def icon(%{name: "hero-" <> _} = assigns) do 13 | ~H""" 14 | 15 | """ 16 | end 17 | 18 | def icon(%{name: "tabler-" <> _} = assigns) do 19 | ~H""" 20 | 21 | """ 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/atomic_web/components/legal_pages_links.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.Components.LegalPagesLinks do 2 | @moduledoc """ 3 | Contains the structure for the legal pages navigation 4 | """ 5 | use AtomicWeb, :component 6 | 7 | def legal_pages_links(assigns) do 8 | ~H""" 9 |
10 | <.link navigate={~p"/tos"} class="shrink-0 select-none"> 11 |

{gettext("Terms of Service")}

12 | 13 | <.link navigate={~p"/privacy"} class="shrink-0 select-none"> 14 |

{gettext("Privacy Policy")}

15 | 16 | <.link navigate={~p"/cookies"} class="shrink-0 select-none"> 17 |

{gettext("Cookie Policy")}

18 | 19 | © 2025 CeSIUM 20 |
21 | """ 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/atomic_web/components/page.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.Components.Page do 2 | @moduledoc """ 3 | Component for the main page layout. 4 | """ 5 | use Phoenix.Component 6 | 7 | attr :title, :string, required: true, doc: "The title of the page." 8 | 9 | attr :bottom_border, :boolean, 10 | default: false, 11 | doc: "Whether to show a bottom border after the page header." 12 | 13 | slot :actions, required: false, doc: "Slot for actions to be rendered in the page header." 14 | slot :inner_block, required: false, doc: "Slot for the body content of the page." 15 | 16 | def page(assigns) do 17 | ~H""" 18 |
19 |
20 |
21 |
22 |
23 |

24 | {@title} 25 |

26 | {render_slot(@actions)} 27 |
28 |
29 | 30 | {render_slot(@inner_block)} 31 |
32 |
33 |
34 | """ 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/atomic_web/components/socials.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.Components.Socials do 2 | @moduledoc false 3 | 4 | use AtomicWeb, :component 5 | 6 | attr :entity, :map, required: true 7 | 8 | def socials(assigns) do 9 | assigns = assign(assigns, :socials_with_values, get_social_values(assigns.entity)) 10 | 11 | ~H""" 12 |
13 | <%= for {social, icon, url_base, social_value} <- assigns.socials_with_values do %> 14 | <%= if social_value do %> 15 |
16 | icon} class="h-5 w-5" alt={Atom.to_string(social)} /> 17 | <.link class="capitalize text-blue-500" target="_blank" href={url_base <> social_value}> 18 | {Atom.to_string(social)} 19 | 20 |
21 | <% end %> 22 | <% end %> 23 |
24 | """ 25 | end 26 | 27 | defp get_social_values(entity) do 28 | socials = Map.get(entity, :socials, %{}) 29 | 30 | get_socials() 31 | |> Enum.map(fn {social, icon, url_base} -> 32 | social_value = Map.get(socials, social) 33 | {social, icon, url_base, social_value} 34 | end) 35 | end 36 | 37 | def get_socials do 38 | [ 39 | {:tiktok, "tiktok.svg", "https://tiktok.com/"}, 40 | {:instagram, "instagram.svg", "https://instagram.com/"}, 41 | {:facebook, "facebook.svg", "https://facebook.com/"}, 42 | {:x, "x.svg", "https://x.com/"} 43 | ] 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/atomic_web/components/spinner.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.Components.Spinner do 2 | @moduledoc false 3 | use Phoenix.Component 4 | 5 | attr :size, :atom, 6 | values: [:xs, :sm, :md, :lg, :xl], 7 | default: :sm, 8 | doc: "The size of the spinner." 9 | 10 | attr :show, :boolean, default: true, doc: "Show or hide spinner." 11 | 12 | attr :size_class, :string, default: nil, doc: "Custom CSS classes for size. eg: size-4" 13 | 14 | attr :class, :string, default: "", doc: "Additional classes to apply to the component." 15 | 16 | attr :rest, :global 17 | 18 | def spinner(assigns) do 19 | ~H""" 20 | 21 | 22 | 23 | 24 | """ 25 | end 26 | 27 | defp generate_classes(assigns) do 28 | size_classes = assigns.size_class || "atomic-spinner--#{assigns.size}" 29 | 30 | [ 31 | "atomic-spinner #{assigns.class}", 32 | !assigns.show && "hidden", 33 | size_classes 34 | ] 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/atomic_web/components/unauthenticated.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.Components.Unauthenticated do 2 | @moduledoc """ 3 | A component for displaying an unauthenticated state. 4 | """ 5 | use AtomicWeb, :component 6 | 7 | attr :id, :string, default: "unauthenticated-state", required: false 8 | attr :url, :string, default: "users/log_in", required: false 9 | 10 | def unauthenticated_state(assigns) do 11 | ~H""" 12 |
13 | <.icon name="hero-user-circle" class="mx-auto h-12 w-12 text-zinc-400" /> 14 |

{gettext("You are not authenticated")}

15 |

{gettext("Please log in to view this content.")}

16 |
17 | <.button patch={@url} icon="hero-arrow-right-end-on-rectangle-solid" icon_position={:right}> 18 | {gettext("Log In")} 19 | 20 |
21 |
22 | """ 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/atomic_web/controllers/sitemap_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.Controllers.SitemapController do 2 | use AtomicWeb, :controller 3 | 4 | @host System.get_env("PHX_HOST") || "localhost:4000" 5 | 6 | def index(conn, _params) do 7 | paths = [ 8 | "/", 9 | "/activities", 10 | "/organizations", 11 | "/announcements", 12 | "/tos", 13 | "/privacy", 14 | "/cookies" 15 | ] 16 | 17 | urls = Enum.map(paths, &build_path/1) 18 | 19 | xml = """ 20 | 21 | 22 | #{Enum.map_join(urls, "\n", fn url -> "#{url}" end)} 23 | 24 | """ 25 | 26 | conn 27 | |> put_resp_content_type("application/xml") 28 | |> send_resp(200, xml) 29 | end 30 | 31 | defp build_path(path), do: "https://#{@host}#{path}" 32 | end 33 | -------------------------------------------------------------------------------- /lib/atomic_web/controllers/user_change_password_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.UserChangePasswordController do 2 | use AtomicWeb, :controller 3 | 4 | alias Atomic.Accounts 5 | alias AtomicWeb.UserAuth 6 | 7 | plug :assign_password_changeset 8 | 9 | def edit(conn, _params) do 10 | render(conn, "edit.html", error_message: nil) 11 | end 12 | 13 | def update(conn, %{"user" => user_params}) do 14 | user = conn.assigns.current_user 15 | 16 | case Accounts.update_user_password(user, user_params["current_password"], user_params) do 17 | {:ok, user} -> 18 | conn 19 | |> put_flash(:info, "Password updated successfully.") 20 | |> put_session(:user_return_to, ~p"/users/change_password") 21 | |> UserAuth.log_in_user(user) 22 | 23 | {:error, changeset} -> 24 | render(conn, "edit.html", changeset: changeset, error_message: "Password didn't change.") 25 | end 26 | end 27 | 28 | defp assign_password_changeset(conn, _opts) do 29 | user = conn.assigns.current_user 30 | 31 | conn 32 | |> assign(:changeset, Accounts.change_user_password(user)) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/atomic_web/controllers/user_confirmation_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.UserConfirmationController do 2 | use AtomicWeb, :controller 3 | 4 | alias Atomic.Accounts 5 | 6 | def new(conn, _params) do 7 | render(conn, "new.html", error_message: nil) 8 | end 9 | 10 | def create(conn, %{"user" => %{"email" => email}}) do 11 | if user = Accounts.get_user_by_email(email) do 12 | Accounts.deliver_user_confirmation_instructions( 13 | user, 14 | &url(~p"/users/confirm/#{&1}") 15 | ) 16 | end 17 | 18 | conn 19 | |> put_flash( 20 | :info, 21 | "If your email is in our system and it has not been confirmed yet, " <> 22 | "you will receive an email with instructions shortly." 23 | ) 24 | |> redirect(to: "/") 25 | end 26 | 27 | def edit(conn, %{"token" => token}) do 28 | update(conn, token) 29 | end 30 | 31 | # Do not log in the user after confirmation to avoid a 32 | # leaked token giving the user access to the account. 33 | def update(conn, token) do 34 | case Accounts.confirm_user(token) do 35 | {:ok, _} -> 36 | conn 37 | |> put_flash( 38 | :info, 39 | "User confirmed successfully. Please log in to continue account setup." 40 | ) 41 | |> redirect(to: "/users/log_in") 42 | 43 | :error -> 44 | # If there is a current user and the account was already confirmed, 45 | # then odds are that the confirmation link was already visited, either 46 | # by some automation or by the user themselves, so we redirect without 47 | # a warning message. 48 | case conn.assigns do 49 | %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> 50 | redirect(conn, to: "/") 51 | 52 | %{} -> 53 | conn 54 | |> put_flash(:error, "User confirmation link is invalid or it has expired.") 55 | |> redirect(to: "/users/log_in") 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/atomic_web/controllers/user_registration_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.UserRegistrationController do 2 | use AtomicWeb, :controller 3 | 4 | alias Atomic.Accounts 5 | 6 | def create(conn, %{"user" => user_params}) do 7 | if user_params["password"] == user_params["confirm_password"] do 8 | case Accounts.register_user(user_params) do 9 | {:ok, user} -> 10 | {:ok, _} = 11 | Accounts.deliver_user_confirmation_instructions( 12 | user, 13 | &url(~p"/users/confirm/#{&1}") 14 | ) 15 | 16 | conn 17 | |> put_flash( 18 | :info, 19 | "Registered successfully. Check your email inbox before continuing." 20 | ) 21 | |> redirect(to: ~p"/users/register") 22 | 23 | {:error, %Ecto.Changeset{} = _changeset} -> 24 | conn 25 | |> put_flash(:error, "Unable to register. This email may already be registered.") 26 | |> redirect(to: ~p"/users/register") 27 | end 28 | else 29 | conn 30 | |> put_flash(:error, "Passwords don't match.") 31 | |> redirect(to: ~p"/users/register") 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/atomic_web/controllers/user_reset_password_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.UserResetPasswordController do 2 | use AtomicWeb, :controller 3 | 4 | alias Atomic.Accounts 5 | 6 | plug :get_user_by_reset_password_token when action in [:edit, :update] 7 | 8 | def new(conn, _params) do 9 | render(conn, "new.html", error_message: nil) 10 | end 11 | 12 | def create(conn, %{"user" => %{"input" => input}}) do 13 | user = Accounts.get_user_by_email(input) || Accounts.get_user_by_slug(input) 14 | 15 | if user do 16 | Accounts.deliver_user_reset_password_instructions( 17 | user, 18 | &url(~p"/users/reset_password/#{&1}") 19 | ) 20 | end 21 | 22 | conn 23 | |> put_flash( 24 | :info, 25 | "If your email or username is in our system, you will receive instructions to reset your password shortly." 26 | ) 27 | |> redirect(to: ~p"/users/log_in") 28 | end 29 | 30 | def edit(conn, _params) do 31 | render(conn, "edit.html", 32 | changeset: Accounts.change_user_password(conn.assigns.user), 33 | error_message: nil 34 | ) 35 | end 36 | 37 | # Do not log in the user after reset password to avoid a 38 | # leaked token giving the user access to the account. 39 | def update(conn, %{"user" => user_params}) do 40 | case Accounts.reset_user_password(conn.assigns.user, user_params) do 41 | {:ok, _} -> 42 | conn 43 | |> put_flash(:info, "Password changed successfully.") 44 | |> redirect(to: ~p"/users/log_in") 45 | 46 | {:error, changeset} -> 47 | render(conn, "edit.html", changeset: changeset, error_message: nil) 48 | end 49 | end 50 | 51 | defp get_user_by_reset_password_token(conn, _opts) do 52 | %{"token" => token} = conn.params 53 | 54 | if user = Accounts.get_user_by_reset_password_token(token) do 55 | conn |> assign(:user, user) |> assign(:token, token) 56 | else 57 | conn 58 | |> put_flash(:error, "Reset password link is invalid or it has expired.") 59 | |> redirect(to: "/404") 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/atomic_web/controllers/user_session_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.UserSessionController do 2 | use AtomicWeb, :controller 3 | 4 | alias Atomic.Accounts 5 | alias AtomicWeb.UserAuth 6 | 7 | def new(conn, %{"user" => user_params}) do 8 | case Accounts.register_user(user_params) do 9 | {:ok, %{user: user, attendee: _}} -> 10 | {:ok, _} = 11 | Accounts.deliver_user_confirmation_instructions( 12 | user, 13 | &url(~p"/users/confirm/#{&1}") 14 | ) 15 | 16 | conn 17 | |> UserAuth.log_in_user(user, user_params) 18 | |> put_flash(:success, "Registered successfully") 19 | |> redirect(to: ~p"/users/setup") 20 | 21 | {:error, _, %Ecto.Changeset{} = _changeset, _} -> 22 | conn 23 | |> put_flash(:error, "Unable to register. This email may already be registered.") 24 | |> redirect(to: ~p"/users/register") 25 | end 26 | end 27 | 28 | def create(conn, %{"user" => user_params}) do 29 | %{"email" => email, "password" => password} = user_params 30 | user = Accounts.get_user_by_email_and_password(email, password) 31 | 32 | if user do 33 | if is_nil(user.confirmed_at) do 34 | conn 35 | |> put_flash(:error, "You need to confirm your email address.") 36 | |> redirect(to: ~p"/users/log_in") 37 | else 38 | UserAuth.log_in_user(conn, user, user_params) 39 | end 40 | else 41 | # In order to prevent user enumeration attacks, don't disclose whether the email is registered. 42 | conn 43 | |> put_flash(:error, "Invalid email or password.") 44 | |> redirect(to: ~p"/users/log_in") 45 | end 46 | end 47 | 48 | def delete(conn, _params) do 49 | conn 50 | |> put_flash(:info, "Logged out successfully.") 51 | |> UserAuth.log_out_user() 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/atomic_web/controllers/user_setup_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.UserSetupController do 2 | use AtomicWeb, :controller 3 | 4 | alias Atomic.Accounts 5 | 6 | @forbidden_characters "!#$%&'*+-/=?^`{|}~" 7 | 8 | def edit(conn, _params) do 9 | user = conn.assigns.current_user 10 | courses = Accounts.list_courses() 11 | 12 | recommended_slug = 13 | String.replace( 14 | extract_email_address_local_part(user.email), 15 | ~r/[#{@forbidden_characters}]+/, 16 | "" 17 | ) 18 | 19 | changeset = Accounts.change_user_setup(Map.put(user, :slug, recommended_slug)) 20 | 21 | render(conn, "edit.html", changeset: changeset, courses: courses) 22 | end 23 | 24 | def finish(conn, %{"user" => user_params}) do 25 | user = conn.assigns.current_user 26 | 27 | case Accounts.finish_user_setup(user, user_params) do 28 | {:ok, _user} -> 29 | conn 30 | |> put_flash(:info, "Account setup complete.") 31 | |> redirect(to: "/organizations") 32 | 33 | {:error, %Ecto.Changeset{} = changeset} -> 34 | render(conn, "edit.html", changeset: changeset, courses: Accounts.list_courses()) 35 | end 36 | end 37 | 38 | defp extract_email_address_local_part(email) do 39 | segments = 40 | email 41 | |> String.split("@") 42 | 43 | List.first(segments) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/atomic_web/emails/activity_emails.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.ActivityEmails do 2 | @moduledoc """ 3 | A module to build activity related emails. 4 | """ 5 | use Phoenix.Swoosh, view: AtomicWeb.EmailView 6 | 7 | def activity_certificate_email(enrollment, activity, organizations, certificate, to: email) do 8 | base_email(to: email) 9 | |> subject("[Atomic] Certificado de Participação em \"#{activity.title}\"") 10 | |> assign(:enrollment, enrollment) 11 | |> assign(:activity, activity) 12 | |> assign(:organizations, organizations) 13 | |> attachment(certificate) 14 | |> render_body("activity_certificate.html") 15 | end 16 | 17 | defp base_email(to: email) do 18 | new() 19 | |> from({"Atomic", "noreply@atomic.cesium.pt"}) 20 | |> to(email) 21 | |> reply_to("caos@cesium.di.uminho.pt") 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/atomic_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :atomic 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_atomic_key", 10 | signing_salt: "2VgE/CCH" 11 | ] 12 | 13 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 14 | 15 | # Serve at "/" the static files from "priv/static" directory. 16 | # 17 | # You should set gzip to true if you are running phx.digest 18 | # when deploying your static files in production. 19 | plug Plug.Static, 20 | at: "/", 21 | from: :atomic, 22 | gzip: false, 23 | only: AtomicWeb.static_paths() 24 | 25 | plug(Plug.Static, 26 | at: "/uploads", 27 | from: Path.expand("./priv/uploads"), 28 | gzip: false 29 | ) 30 | 31 | # Code reloading can be explicitly enabled under the 32 | # :code_reloader configuration of your endpoint. 33 | if code_reloading? do 34 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 35 | plug Phoenix.LiveReloader 36 | plug Phoenix.CodeReloader 37 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :atomic 38 | end 39 | 40 | plug Phoenix.LiveDashboard.RequestLogger, 41 | param_key: "request_logger", 42 | cookie_key: "request_logger" 43 | 44 | plug Plug.RequestId 45 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 46 | 47 | plug Plug.Parsers, 48 | parsers: [:urlencoded, :multipart, :json], 49 | pass: ["*/*"], 50 | json_decoder: Phoenix.json_library() 51 | 52 | plug Plug.MethodOverride 53 | plug Plug.Head 54 | plug Plug.Session, @session_options 55 | plug AtomicWeb.Router 56 | end 57 | -------------------------------------------------------------------------------- /lib/atomic_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import AtomicWeb.Gettext 9 | 10 | # Simple translation 11 | gettext("Here is the string to translate") 12 | 13 | # Plural translation 14 | ngettext("Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3) 17 | 18 | # Domain-based translation 19 | dgettext("errors", "Here is the error message to translate") 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext.Backend, otp_app: :atomic 24 | end 25 | -------------------------------------------------------------------------------- /lib/atomic_web/live/activity_live/edit.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.ActivityLive.Edit do 2 | @moduledoc false 3 | use AtomicWeb, :live_view 4 | 5 | alias Atomic.Activities 6 | 7 | import AtomicWeb.LiveHelpers 8 | 9 | @impl true 10 | def mount(_params, _session, socket) do 11 | {:ok, socket} 12 | end 13 | 14 | @impl true 15 | def handle_params(%{"id" => id}, _, socket) do 16 | activity = Activities.get_activity!(id, [:organization]) 17 | 18 | {:noreply, 19 | socket 20 | |> assign(:page_title, "Edit Activity") 21 | |> assign_page_metadata(:edit_activity) 22 | |> assign(:current_page, :activities) 23 | |> assign(:current_organization, activity.organization) 24 | |> assign(:activity, activity)} 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/atomic_web/live/activity_live/edit.html.heex: -------------------------------------------------------------------------------- 1 |
2 | <.live_component module={AtomicWeb.ActivityLive.FormComponent} id={@activity.id} title={@page_title} action={@live_action} activity={@activity} current_organization={@current_organization} return_to={~p"/activities/#{@activity}"} /> 3 |
4 | -------------------------------------------------------------------------------- /lib/atomic_web/live/activity_live/new.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.ActivityLive.New do 2 | @moduledoc false 3 | use AtomicWeb, :live_view 4 | 5 | alias Atomic.Activities.Activity 6 | 7 | import AtomicWeb.LiveHelpers 8 | 9 | @impl true 10 | def mount(_params, _session, socket) do 11 | {:ok, assign(socket, activity: %Activity{})} 12 | end 13 | 14 | @impl true 15 | def handle_params(%{"organization_id" => organization_id}, _, socket) do 16 | {:noreply, 17 | socket 18 | |> assign(:page_title, "New Activity") 19 | |> assign_page_metadata(:new_activity) 20 | |> assign(:current_page, :activities) 21 | |> assign(:organization_id, organization_id)} 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/atomic_web/live/activity_live/new.html.heex: -------------------------------------------------------------------------------- 1 |
2 | <.live_component module={AtomicWeb.ActivityLive.FormComponent} id={:new} title={@page_title} action={@live_action} activity={@activity} current_organization={@current_organization} return_to={~p"/activities"} /> 3 |
4 | -------------------------------------------------------------------------------- /lib/atomic_web/live/announcement_live/components/announcement_card.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.AnnouncementLive.Components.AnnouncementCard do 2 | @moduledoc false 3 | 4 | import AtomicWeb.Components.Avatar 5 | 6 | use AtomicWeb, :component 7 | 8 | def announcement_card(assigns) do 9 | ~H""" 10 |
11 | <.link navigate={~p"/organizations/#{@organization}/announcements/#{@announcement}"} class="block"> 12 |
13 |
14 | <.avatar name={@announcement.organization.name} color={:light_zinc} class="!h-10 !w-10" size={:xs} type={:organization} src={Uploaders.Logo.url({@announcement.organization.logo, @announcement.organization}, :original)} /> 15 |
16 |
17 |

{@announcement.organization.name}

18 |

19 | Published on 20 | 21 |

22 |
23 |
24 |
25 |

26 | {@announcement.title} 27 |

28 |

29 | {maybe_slice_string(@announcement.description, 300)} 30 |

31 |
32 | <%= if @announcement.image do %> 33 |
34 | Announcement Image 35 |
36 | <% end %> 37 | 38 |
39 | """ 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/atomic_web/live/announcement_live/edit.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.AnnouncementLive.Edit do 2 | @moduledoc false 3 | use AtomicWeb, :live_view 4 | 5 | alias Atomic.Organizations 6 | 7 | import AtomicWeb.LiveHelpers 8 | 9 | @impl true 10 | def mount(_params, _session, socket) do 11 | {:ok, socket} 12 | end 13 | 14 | @impl true 15 | def handle_event("delete", _params, socket) do 16 | Organizations.delete_announcement(socket.assigns.announcement) 17 | 18 | {:noreply, 19 | socket 20 | |> put_flash(:info, gettext("Announcement deleted successfully")) 21 | |> push_navigate( 22 | to: ~p"/organizations/#{socket.assigns.current_organization.id}/announcements" 23 | )} 24 | end 25 | 26 | @impl true 27 | def handle_params(%{"organization_id" => organization_id, "id" => id}, _, socket) do 28 | announcement = Organizations.get_announcement!(id) 29 | organization = Organizations.get_organization!(organization_id) 30 | 31 | {:noreply, 32 | socket 33 | |> assign(:page_title, gettext("Edit Announcement")) 34 | |> assign_page_metadata(:edit_announcement) 35 | |> assign(:current_page, :activities) 36 | |> assign(:announcement, announcement) 37 | |> assign(:current_organization, organization)} 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/atomic_web/live/announcement_live/edit.html.heex: -------------------------------------------------------------------------------- 1 | <.page title={gettext("Edit Announcement")}> 2 | <:actions> 3 | <.button size={:md} icon="hero-trash" color={:white} type="delete" phx-click="delete"> 4 | {gettext("Delete")} 5 | 6 | 7 |
8 | <.live_component module={AtomicWeb.AnnouncementLive.FormComponent} id={@announcement.id} organization={@current_organization} title={@page_title} action={@live_action} announcement={@announcement} return_to={~p"/organizations/#{@current_organization}/announcements"} /> 9 |
10 | 11 | -------------------------------------------------------------------------------- /lib/atomic_web/live/announcement_live/form_component.html.heex: -------------------------------------------------------------------------------- 1 |
2 | <.form :let={f} for={@changeset} id="announcement-form" phx-target={@myself} phx-change="validate" phx-submit="save" class="space-y-6"> 3 |
4 |
5 |
6 | <.field field={f[:title]} type="text" placeholder="Title" required class="w-full" /> 7 | 8 | <.field field={f[:description]} type="textarea" placeholder="Description" required class="h-44 w-full resize-none overflow-auto xl:h-64" /> 9 |
10 |
11 | <.live_component module={ImageUploader} id="uploader" uploads={@uploads} target={@myself} class="object-cover" /> 12 | 13 |
14 | <.button size={:md} color={:white} icon="hero-cube" type="submit">{gettext("Save Changes")} 15 |
16 |
17 |
18 |
19 | 20 |
21 | -------------------------------------------------------------------------------- /lib/atomic_web/live/announcement_live/index.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.AnnouncementLive.Index do 2 | use AtomicWeb, :live_view 3 | 4 | import AtomicWeb.Components.{Button, Empty, Pagination} 5 | import AtomicWeb.AnnouncementLive.Components.AnnouncementCard 6 | import AtomicWeb.LiveHelpers 7 | 8 | alias Atomic.Accounts 9 | alias Atomic.Organizations 10 | 11 | @impl true 12 | def mount(_params, _session, socket) do 13 | {:ok, socket} 14 | end 15 | 16 | @impl true 17 | def handle_params(%{"organization_id" => organization_id} = params, _, socket) do 18 | organization = Organizations.get_organization!(organization_id) 19 | 20 | {:noreply, 21 | socket 22 | |> assign(:page_title, gettext("Announcements")) 23 | |> assign_page_metadata(:announcements) 24 | |> assign(:current_page, :announcements) 25 | |> assign(:organization, organization) 26 | |> assign(:params, params) 27 | |> assign(:has_permissions?, has_permissions?(socket)) 28 | |> assign(list_announcements_by_organization(socket, params, organization_id)) 29 | |> then(fn complete_socket -> 30 | assign(complete_socket, :empty?, Enum.empty?(complete_socket.assigns.announcements)) 31 | end)} 32 | end 33 | 34 | defp list_announcements_by_organization(_socket, params, organization_id) do 35 | case Organizations.list_announcements_by_organization_id(organization_id, params, 36 | preloads: [:organization] 37 | ) do 38 | {:ok, {announcements, meta}} -> 39 | %{announcements: announcements, meta: meta} 40 | 41 | {:error, flop} -> 42 | %{announcements: [], meta: flop} 43 | end 44 | end 45 | 46 | defp has_permissions?(socket) when not socket.assigns.is_authenticated?, do: false 47 | 48 | defp has_permissions?(socket) do 49 | has_current_organization?(socket) and 50 | (Accounts.has_permissions_inside_organization?( 51 | socket.assigns.current_user.id, 52 | socket.assigns.current_organization.id 53 | ) or Accounts.has_master_permissions?(socket.assigns.current_user.id)) 54 | end 55 | 56 | defp has_current_organization?(socket) do 57 | is_map_key(socket.assigns, :current_organization) and 58 | not is_nil(socket.assigns.current_organization) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/atomic_web/live/announcement_live/index.html.heex: -------------------------------------------------------------------------------- 1 | <.page title="Announcements" bottom_border={true}> 2 | <:actions> 3 | <%= if not @empty? and @has_permissions? do %> 4 | <.button navigate={~p"/organizations/#{@current_organization}/announcements/new"} icon="hero-plus"> 5 | {gettext("New Announcement")} 6 | 7 | <% end %> 8 | 9 | 10 | <%= if @empty? and @has_permissions? do %> 11 |
12 | <.empty_state url={~p"/organizations/#{@organization}/announcements/new"} placeholder="announcement" /> 13 |
14 | <% else %> 15 |
16 |
    17 | <%= for announcement <- @announcements do %> 18 |
  • 19 | <.announcement_card announcement={announcement} organization={@organization} /> 20 |
  • 21 | <% end %> 22 |
23 | <.pagination items={@announcements} meta={@meta} params={@params} class="mt-2 flex w-full items-center justify-between" /> 24 |
25 | <% end %> 26 | 27 | -------------------------------------------------------------------------------- /lib/atomic_web/live/announcement_live/new.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.AnnouncementLive.New do 2 | @moduledoc false 3 | use AtomicWeb, :live_view 4 | 5 | alias Atomic.Organizations.Announcement 6 | 7 | import AtomicWeb.LiveHelpers 8 | 9 | @impl true 10 | def mount(_params, _session, socket) do 11 | {:ok, socket} 12 | end 13 | 14 | @impl true 15 | def handle_params(%{"organization_id" => organization_id}, _, socket) do 16 | {:noreply, 17 | socket 18 | |> assign(:page_title, gettext("New Announcement")) 19 | |> assign_page_metadata(:new_announcement) 20 | |> assign(:current_page, :announcements) 21 | |> assign(:announcement, %Announcement{organization_id: organization_id})} 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/atomic_web/live/announcement_live/new.html.heex: -------------------------------------------------------------------------------- 1 | <.page title={gettext("New Announcement")}> 2 |
3 | <.live_component module={AtomicWeb.AnnouncementLive.FormComponent} id={:new} organization={@current_organization} title={@page_title} action={@live_action} announcement={@announcement} return_to={~p"/organizations/#{@current_organization}/announcements"} /> 4 |
5 | 6 | -------------------------------------------------------------------------------- /lib/atomic_web/live/announcement_live/show.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.AnnouncementLive.Show do 2 | use AtomicWeb, :live_view 3 | 4 | import AtomicWeb.Components.Avatar 5 | import AtomicWeb.LiveHelpers 6 | 7 | alias Atomic.Accounts 8 | alias Atomic.Organizations 9 | 10 | @impl true 11 | def mount(_params, _session, socket) do 12 | {:ok, socket} 13 | end 14 | 15 | @impl true 16 | def handle_params(%{"id" => id} = _params, _, socket) do 17 | announcement = Organizations.get_announcement!(id, preloads: [:organization]) 18 | 19 | {:noreply, 20 | socket 21 | |> assign(:page_title, announcement.title) 22 | |> assign_page_metadata(:announcement) 23 | |> assign(:current_page, :announcements) 24 | |> assign(:announcement, announcement) 25 | |> assign(:has_permissions?, has_permissions?(socket |> assign(:announcement, announcement)))} 26 | end 27 | 28 | defp has_permissions?(socket) when not socket.assigns.is_authenticated?, do: false 29 | 30 | defp has_permissions?(socket) 31 | when not is_map_key(socket.assigns, :current_organization) or 32 | is_nil(socket.assigns.current_organization) do 33 | Accounts.has_master_permissions?(socket.assigns.current_user.id) 34 | end 35 | 36 | defp has_permissions?(socket) do 37 | Accounts.has_master_permissions?(socket.assigns.current_user.id) || 38 | Accounts.has_permissions_inside_organization?( 39 | socket.assigns.current_user.id, 40 | socket.assigns.announcement.organization.id 41 | ) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/atomic_web/live/announcement_live/show.html.heex: -------------------------------------------------------------------------------- 1 | <.page title="Announcements" bottom_border={true}> 2 | <:actions> 3 | <%= if @has_permissions? do %> 4 | <.button navigate={~p"/organizations/#{@announcement.organization}/announcements/#{@announcement}/edit"} icon="hero-pencil-solid">{gettext("Edit Announcement")} 5 | <% end %> 6 | 7 |
8 |
9 |
10 |
11 | <.avatar name={@announcement.organization.name} color={:light_zinc} size={:md} type={:organization} src={Uploaders.Logo.url({@announcement.organization.logo, @announcement.organization}, :original)} /> 12 |
13 |
14 | <.link navigate={~p"/organizations/#{@announcement.organization.id}"} class="hover:underline focus:outline-none"> 15 |

{@announcement.organization.name}

16 | 17 |

18 | Published on 19 | 20 |

21 |
22 |
23 |
24 |

25 | {@announcement.title} 26 |

27 |

28 | 29 | <%= Enum.map(String.split(@announcement.description, "\n"), fn phrase -> %> 30 | {phrase} 31 | <% end) %> 32 | 33 |

34 |
35 | <%= if @announcement.image do %> 36 |
37 | Announcement Image 38 |
39 | <% end %> 40 |
41 |
42 | 43 | -------------------------------------------------------------------------------- /lib/atomic_web/live/department_live/components/department_card.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.DepartmentLive.Components.DepartmentCard do 2 | @moduledoc false 3 | use AtomicWeb, :component 4 | 5 | import AtomicWeb.Components.{Avatar, Badge, Gradient} 6 | 7 | attr :department, :map, required: true, doc: "The department to display." 8 | attr :collaborators, :list, required: true, doc: "The list of collaborators in the department." 9 | 10 | def department_card(assigns) do 11 | ~H""" 12 |
13 |
14 | <%= if @department.banner do %> 15 | 16 | <% else %> 17 | <.gradient seed={@department.id} class="rounded-t-lg" /> 18 | <% end %> 19 |
20 |
21 |
22 |

{@department.name}

23 | <.badge :if={@department.archived} variant={:outline} color={:warning} size={:md} class="bg-yellow-300/5 select-none rounded-xl border-yellow-400 py-1 font-normal text-yellow-400 sm:ml-auto sm:py-0"> 24 |

{gettext("Archived")}

25 | 26 |
27 | <.avatar_group 28 | size={:xs} 29 | color={:light_zinc} 30 | spacing={-2} 31 | class="min-h-8 mt-4 mb-2" 32 | items={ 33 | @collaborators 34 | |> Enum.take(4) 35 | |> Enum.map(fn person -> 36 | %{ 37 | name: person.user.name 38 | } 39 | end) 40 | |> then(fn avatars -> 41 | if length(@collaborators) > 4 do 42 | Enum.concat(avatars, [%{name: "+#{length(@collaborators) - 4}", auto_generate_initials: false}]) 43 | else 44 | avatars 45 | end 46 | end) 47 | } 48 | /> 49 |
50 |
51 | """ 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/atomic_web/live/department_live/index.html.heex: -------------------------------------------------------------------------------- 1 | <.page title="Departments"> 2 | <:actions> 3 | <%= if not @empty? and @has_permissions? do %> 4 | <.button patch={~p"/organizations/#{@current_organization}/departments/new"} icon="hero-plus"> 5 | {gettext("New")} 6 | 7 | <% end %> 8 | 9 | 10 | <%= if @empty? and @has_permissions? do %> 11 |
12 | <.empty_state url={~p"/organizations/#{@current_organization}/departments/new"} placeholder="department" /> 13 |
14 | <% else %> 15 |
16 | <%= for {department, collaborators} <- @departments do %> 17 | <.link navigate={~p"/organizations/#{@current_organization}/departments/#{department}"}> 18 | <.department_card department={department} collaborators={collaborators} /> 19 | 20 | <% end %> 21 |
22 | <% end %> 23 | 24 | -------------------------------------------------------------------------------- /lib/atomic_web/live/home_live/components/follow_suggestions/follow_suggestions.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.HomeLive.Components.FollowSuggestions do 2 | @moduledoc false 3 | use AtomicWeb, :component 4 | 5 | alias __MODULE__.Suggestion 6 | 7 | attr :current_user, :map, 8 | required: true, 9 | doc: "The current user logged in." 10 | 11 | attr :organizations, :list, 12 | required: true, 13 | doc: "Organizations displayed as follow suggestions." 14 | 15 | def follow_suggestions(assigns) do 16 | ~H""" 17 |
18 |

19 | {title(@current_user)} 20 |

21 |
22 |
    23 | <%= for organization <- @organizations do %> 24 | <.live_component id={organization.id} module={Suggestion} organization={organization} current_user={@current_user} /> 25 | <% end %> 26 |
27 |
28 |
29 | <.button patch={~p"/organizations"} color={:white} size={:md} full_width> 30 | {gettext("View all")} 31 | 32 |
33 |
34 | """ 35 | end 36 | 37 | defp title(current_user) when is_nil(current_user), do: gettext("Top organizations") 38 | 39 | defp title(_current_user), do: gettext("Organizations you may like") 40 | end 41 | -------------------------------------------------------------------------------- /lib/atomic_web/live/hooks.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.Hooks do 2 | @moduledoc """ 3 | Ensures common `assigns` are applied to all LiveViews attaching this hook. 4 | """ 5 | import Phoenix.LiveView 6 | import Phoenix.Component 7 | 8 | alias Atomic.Accounts 9 | 10 | def on_mount(:current_user_state, _params, session, socket) do 11 | current_user = maybe_get_current_user(session) 12 | socket = socket |> assign(:timezone, get_timezone(socket)) 13 | 14 | {:cont, 15 | socket 16 | |> assign(:current_user, current_user) 17 | |> assign(:current_organization, maybe_get_current_organization(session)) 18 | |> assign(:is_authenticated?, !is_nil(current_user))} 19 | end 20 | 21 | defp maybe_get_current_user(session) do 22 | case session["user_token"] do 23 | nil -> 24 | nil 25 | 26 | user_token -> 27 | Accounts.get_user_by_session_token(user_token) 28 | end 29 | end 30 | 31 | defp maybe_get_current_organization(session) do 32 | case maybe_get_current_user(session) do 33 | nil -> 34 | nil 35 | 36 | current_user -> 37 | current_user.current_organization 38 | end 39 | end 40 | 41 | defp get_timezone(socket) do 42 | timezone = Application.get_env(:atomic, :timezone) 43 | get_connect_params(socket)["timezone"] || timezone 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/atomic_web/live/legal_terms_live/components/black_bar.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.LegalTermsLive.Components.BlackBar do 2 | @moduledoc """ 3 | Component for Legal Pages Black Bar. 4 | """ 5 | use Phoenix.Component 6 | use AtomicWeb, :component 7 | 8 | def black_bar(assigns) do 9 | ~H""" 10 |
11 |

12 | {gettext("Lorem ipsum dolor sit amet.")} 13 |

14 |
15 | """ 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/atomic_web/live/legal_terms_live/components/header.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.LegalTermsLive.Components.Header do 2 | @moduledoc """ 3 | Component for Legal Pages Header. 4 | """ 5 | use Phoenix.Component 6 | use AtomicWeb, :component 7 | 8 | @pages [ 9 | {"Terms of Service", "tos"}, 10 | {"Privacy Policy", "privacy"}, 11 | {"Cookie Policy", "cookies"} 12 | ] 13 | 14 | defp link_pages(current_page) do 15 | Enum.map(@pages, fn {title, path} -> 16 | if title == current_page do 17 | {:current, title, path} 18 | else 19 | {:link, title, path} 20 | end 21 | end) 22 | end 23 | 24 | def header(assigns) do 25 | ~H""" 26 |
27 |
28 |
29 | 30 | <.link navigate={~p"/"}> 31 | 32 | 33 |
34 | 44 |
45 | 46 |
47 | <.button class="atomic-button atomic-button--white atomic-button--md hidden sm:block" patch={~p"/"}>{gettext("Back Home")} 48 | <.button class="atomic-button atomic-button--md hero-home block text-zinc-400 sm:hidden" patch={~p"/"}> 49 |
50 |
51 | """ 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/atomic_web/live/legal_terms_live/components/main_title.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.LegalTermsLive.Components.MainTitle do 2 | @moduledoc """ 3 | Component for Legal Pages Main Title. 4 | """ 5 | use Phoenix.Component 6 | use AtomicWeb, :component 7 | 8 | def main_title(assigns) do 9 | ~H""" 10 |
11 | {@page_title} 12 |
13 | """ 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/atomic_web/live/legal_terms_live/cookies_live/show.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.CookiesLive.Show do 2 | use AtomicWeb, :live_view 3 | 4 | import AtomicWeb.LegalTermsLive.Components.{Header, MainTitle, BlackBar} 5 | import AtomicWeb.LiveHelpers 6 | 7 | @impl true 8 | def mount(_params, _session, socket) do 9 | {:ok, socket, layout: false} 10 | end 11 | 12 | @impl true 13 | def handle_params(_params, _, socket) do 14 | {:noreply, 15 | socket 16 | |> assign(:page_title, gettext("Cookies")) 17 | |> assign_page_metadata(:cookies) 18 | |> assign(:current_page, :cookies)} 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/atomic_web/live/legal_terms_live/privacy_live/show.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.PrivacyLive.Show do 2 | use AtomicWeb, :live_view 3 | 4 | import AtomicWeb.LegalTermsLive.Components.{Header, MainTitle, BlackBar} 5 | import AtomicWeb.LiveHelpers 6 | 7 | @impl true 8 | def mount(_params, _session, socket) do 9 | {:ok, socket, layout: false} 10 | end 11 | 12 | @impl true 13 | def handle_params(_params, _, socket) do 14 | {:noreply, 15 | socket 16 | |> assign(:page_title, gettext("Privacy Policy")) 17 | |> assign_page_metadata(:privacy) 18 | |> assign(:current_page, :privacy)} 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/atomic_web/live/legal_terms_live/tos_live/show.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.TermsLive.Show do 2 | use AtomicWeb, :live_view 3 | 4 | import AtomicWeb.LegalTermsLive.Components.{Header, MainTitle, BlackBar} 5 | import AtomicWeb.LiveHelpers 6 | 7 | @impl true 8 | def mount(_params, _session, socket) do 9 | {:ok, socket, layout: false} 10 | end 11 | 12 | @impl true 13 | def handle_params(_params, _, socket) do 14 | {:noreply, 15 | socket 16 | |> assign(:page_title, gettext("Terms of Service")) 17 | |> assign_page_metadata(:terms) 18 | |> assign(:current_page, :terms)} 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/atomic_web/live/organization_live/edit.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.OrganizationLive.Edit do 2 | use AtomicWeb, :live_view 3 | 4 | import AtomicWeb.LiveHelpers 5 | 6 | alias Atomic.Organizations 7 | 8 | @impl true 9 | def mount(_params, _session, socket) do 10 | {:ok, socket} 11 | end 12 | 13 | @impl true 14 | def handle_params(%{"organization_id" => organization_id}, _, socket) do 15 | organization = Organizations.get_organization!(organization_id) 16 | 17 | {:noreply, 18 | socket 19 | |> assign(:page_title, organization.name) 20 | |> assign_page_metadata(:organization) 21 | |> assign(:organization, organization) 22 | |> assign(:current_page, :organizations)} 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/atomic_web/live/organization_live/edit.html.heex: -------------------------------------------------------------------------------- 1 | <.live_component module={AtomicWeb.OrganizationLive.FormComponent} id={@organization} title={@page_title} action={@live_action} organization={@organization} return_to={~p"/organizations/#{@organization}"} /> 2 | -------------------------------------------------------------------------------- /lib/atomic_web/live/organization_live/form_component.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.OrganizationLive.FormComponent do 2 | use AtomicWeb, :live_component 3 | 4 | alias Atomic.Organizations 5 | 6 | @impl true 7 | def mount(socket) do 8 | {:ok, socket} 9 | end 10 | 11 | @impl true 12 | def update(%{organization: organization} = assigns, socket) do 13 | changeset = Organizations.change_organization(organization) 14 | 15 | {:ok, 16 | socket 17 | |> assign(assigns) 18 | |> assign(:changeset, changeset)} 19 | end 20 | 21 | @impl true 22 | def handle_event("validate", %{"organization" => organization_params}, socket) do 23 | changeset = 24 | socket.assigns.organization 25 | |> Organizations.change_organization(organization_params) 26 | |> Map.put(:action, :validate) 27 | 28 | {:noreply, assign(socket, :changeset, changeset)} 29 | end 30 | 31 | def handle_event("save", %{"organization" => organization_params}, socket) do 32 | save_organization(socket, socket.assigns.action, organization_params) 33 | end 34 | 35 | defp save_organization(socket, :edit, organization_params) do 36 | case Organizations.update_organization(socket.assigns.organization, organization_params) do 37 | {:ok, _organization} -> 38 | {:noreply, 39 | socket 40 | |> put_flash(:info, "Organization updated successfully") 41 | |> push_navigate(to: socket.assigns.return_to)} 42 | 43 | {:error, %Ecto.Changeset{} = changeset} -> 44 | {:noreply, assign(socket, :changeset, changeset)} 45 | end 46 | end 47 | 48 | defp save_organization(socket, :new, organization_params) do 49 | case Organizations.create_organization(organization_params) do 50 | {:ok, _organization} -> 51 | {:noreply, 52 | socket 53 | |> put_flash(:info, "Organization created successfully") 54 | |> push_navigate(to: socket.assigns.return_to)} 55 | 56 | {:error, %Ecto.Changeset{} = changeset} -> 57 | {:noreply, assign(socket, changeset: changeset)} 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/atomic_web/live/organization_live/form_component.html.heex: -------------------------------------------------------------------------------- 1 |
2 |

{@title}

3 | 4 | <.form :let={f} for={@changeset} id="organization-form" phx-target={@myself} phx-change="validate" phx-submit="save"> 5 | {label(f, :name)} 6 | {text_input(f, :name)} 7 | {error_tag(f, :name)} 8 | 9 | {label(f, :description)} 10 | {text_input(f, :description)} 11 | {error_tag(f, :description)} 12 | 13 |
14 | {submit("Save", phx_disable_with: "Saving...")} 15 |
16 | 17 |
18 | -------------------------------------------------------------------------------- /lib/atomic_web/live/organization_live/index.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.OrganizationLive.Index do 2 | use AtomicWeb, :live_view 3 | 4 | import AtomicWeb.Components.Avatar 5 | import AtomicWeb.Components.Empty 6 | import AtomicWeb.Components.Pagination 7 | import AtomicWeb.Components.Button 8 | import AtomicWeb.LiveHelpers 9 | 10 | alias Atomic.Accounts 11 | alias Atomic.Organizations 12 | 13 | @impl true 14 | def mount(_params, _session, socket) do 15 | {:ok, socket} 16 | end 17 | 18 | @impl true 19 | def handle_params(params, _url, socket) do 20 | organizations_with_flop = list_organizations(params) 21 | 22 | {:noreply, 23 | socket 24 | |> assign(:page_title, gettext("Organizations")) 25 | |> assign_page_metadata(:organizations) 26 | |> assign(:current_page, :organizations) 27 | |> assign(:params, params) 28 | |> assign(organizations_with_flop) 29 | |> assign(:empty?, Enum.empty?(organizations_with_flop.organizations)) 30 | |> assign(:has_permissions?, has_permissions?(socket))} 31 | end 32 | 33 | defp list_organizations(params) do 34 | case Organizations.list_organizations(Map.put(params, "page_size", 18)) do 35 | {:ok, {organizations, meta}} -> 36 | %{organizations: organizations, meta: meta} 37 | 38 | {:error, flop} -> 39 | %{organizations: [], meta: flop} 40 | end 41 | end 42 | 43 | defp has_permissions?(socket) when not socket.assigns.is_authenticated?, do: false 44 | 45 | defp has_permissions?(socket) do 46 | Accounts.has_master_permissions?(socket.assigns.current_user.id) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/atomic_web/live/organization_live/index.html.heex: -------------------------------------------------------------------------------- 1 | <.page title="Organizations"> 2 | <:actions> 3 | <%= if not @empty? and @has_permissions? do %> 4 | <.button navigate={~p"/organizations/new"}> 5 | {gettext("New")} 6 | 7 | <% end %> 8 | 9 | 10 | <%= if @empty? and @has_permissions? do %> 11 |
12 | <.empty_state url={~p"/organizations/new"} placeholder="organization" /> 13 |
14 | <% else %> 15 |
16 | <%= for organization <- @organizations do %> 17 | <.link navigate={~p"/organizations/#{organization.id}"}> 18 |
19 | <.avatar name={organization.name} src={Uploaders.Logo.url({organization.logo, organization}, :original)} type={:organization} size={:lg} color={:light_zinc} /> 20 |
21 |

22 | {organization.name} 23 |

24 | 27 |

28 | {maybe_slice_string(organization.long_name, 85)} 29 |

30 |
31 |
32 | 33 | <% end %> 34 |
35 | <.pagination items={@organizations} meta={@meta} params={@params} class="mt-2 flex w-full items-center justify-between" /> 36 | <% end %> 37 | 38 | -------------------------------------------------------------------------------- /lib/atomic_web/live/organization_live/new.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.OrganizationLive.New do 2 | use AtomicWeb, :live_view 3 | 4 | import AtomicWeb.LiveHelpers 5 | 6 | alias Atomic.Organizations.Organization 7 | 8 | @impl true 9 | def mount(_params, _session, socket) do 10 | {:ok, socket} 11 | end 12 | 13 | @impl true 14 | def handle_params(_params, _, socket) do 15 | {:noreply, 16 | socket 17 | |> assign(:page_title, gettext("New Organization")) 18 | |> assign_page_metadata(:new_organization) 19 | |> assign(:organization, %Organization{}) 20 | |> assign(:current_page, :organization)} 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/atomic_web/live/organization_live/new.html.heex: -------------------------------------------------------------------------------- 1 | <.live_component module={AtomicWeb.OrganizationLive.FormComponent} current_user={@current_user} organization={@current_organization} id={:new} title={@page_title} action={@live_action} return_to={~p"/organizations"} /> 2 | -------------------------------------------------------------------------------- /lib/atomic_web/live/partner_live/show.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.PartnerLive.Show do 2 | use AtomicWeb, :live_view 3 | 4 | import AtomicWeb.Components.{Avatar, Socials} 5 | import AtomicWeb.LiveHelpers 6 | 7 | alias Atomic.Accounts 8 | alias Atomic.Organizations 9 | alias Atomic.Partners 10 | 11 | @impl true 12 | def mount(_params, _session, socket) do 13 | {:ok, socket} 14 | end 15 | 16 | @impl true 17 | def handle_params(%{"organization_id" => organization_id, "id" => id}, _, socket) do 18 | organization = Organizations.get_organization!(organization_id) 19 | partner = Partners.get_partner!(id) 20 | 21 | {:noreply, 22 | socket 23 | |> assign(:page_title, partner.name) 24 | |> assign_page_metadata(:partner) 25 | |> assign(:current_page, :partners) 26 | |> assign(:organization, organization) 27 | |> assign(:partner, partner) 28 | |> assign( 29 | :partners, 30 | Partners.list_partners(where: [organization_id: organization_id, archived: false]) 31 | ) 32 | |> assign(:has_permissions?, has_permissions?(socket, organization_id))} 33 | end 34 | 35 | defp has_permissions?(socket, _organization_id) when not socket.assigns.is_authenticated?, 36 | do: false 37 | 38 | defp has_permissions?(socket, _organization_id) 39 | when not is_map_key(socket.assigns, :current_organization) or 40 | is_nil(socket.assigns.current_organization) do 41 | Accounts.has_master_permissions?(socket.assigns.current_user.id) 42 | end 43 | 44 | defp has_permissions?(socket, organization_id) do 45 | Accounts.has_master_permissions?(socket.assigns.current_user.id) || 46 | Accounts.has_permissions_inside_organization?( 47 | socket.assigns.current_user.id, 48 | organization_id 49 | ) 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/atomic_web/live/profile_live/edit.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.ProfileLive.Edit do 2 | use AtomicWeb, :live_view 3 | 4 | import AtomicWeb.LiveHelpers 5 | 6 | alias Atomic.Accounts 7 | 8 | @impl true 9 | def mount(_params, _session, socket) do 10 | {:ok, socket} 11 | end 12 | 13 | @impl true 14 | def handle_params(%{"slug" => user_slug}, _, socket) do 15 | user = Accounts.get_user_by_slug(user_slug) 16 | 17 | if socket.assigns.current_user && socket.assigns.current_user.slug == user_slug do 18 | {:noreply, 19 | socket 20 | |> assign(:page_title, user.name) 21 | |> assign_page_metadata(:user_profile) 22 | |> assign(:current_page, :profile) 23 | |> assign(:user, user)} 24 | else 25 | {:noreply, socket |> redirect(to: ~p"/profile/#{user_slug}")} 26 | end 27 | end 28 | 29 | def handle_params(%{"token" => token}, _, socket) do 30 | case Accounts.update_user_email(socket.assigns.current_user, token) do 31 | :ok -> 32 | {:noreply, 33 | socket 34 | |> put_flash(:info, "Email changed successfully.") 35 | |> redirect(to: ~p"/profile/#{socket.assigns.current_user}")} 36 | 37 | :error -> 38 | {:noreply, 39 | socket 40 | |> put_flash(:error, "Email change link is invalid or it has expired.") 41 | |> redirect(to: ~p"/profile/#{socket.assigns.current_user}")} 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/atomic_web/live/profile_live/edit.html.heex: -------------------------------------------------------------------------------- 1 | <.live_component id={@user.id} module={AtomicWeb.ProfileLive.FormComponent} user={@user} action={@live_action} /> 2 | -------------------------------------------------------------------------------- /lib/atomic_web/live/profile_live/show.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.ProfileLive.Show do 2 | use AtomicWeb, :live_view 3 | 4 | import AtomicWeb.Components.{Button, Avatar, Gradient, Socials} 5 | import AtomicWeb.Components.ImageUploader 6 | import AtomicWeb.LiveHelpers 7 | 8 | alias Atomic.Accounts 9 | alias Atomic.Organizations 10 | 11 | @extensions_whitelist ~w(.jpg .jpeg .gif .png) 12 | 13 | @impl true 14 | def mount(_params, _session, socket) do 15 | {:ok, 16 | socket 17 | |> allow_upload(:profile_picture, 18 | accept: @extensions_whitelist, 19 | max_entries: 1, 20 | max_file_size: 10_000_000 21 | ) 22 | |> allow_upload(:banner, 23 | accept: @extensions_whitelist, 24 | max_entries: 1, 25 | max_file_size: 100_000_000 26 | )} 27 | end 28 | 29 | @impl true 30 | def handle_params(%{"slug" => user_slug}, _, socket) do 31 | user = Accounts.get_user_by_slug(user_slug) 32 | 33 | is_current_user = 34 | Map.has_key?(socket.assigns, :current_user) and socket.assigns.current_user.id == user.id 35 | 36 | organizations = Organizations.list_user_organizations(user.id) 37 | 38 | {:noreply, 39 | socket 40 | |> assign(:page_title, user.name) 41 | |> assign_page_metadata(:user_profile) 42 | |> assign(:current_page, :profile) 43 | |> assign(:user, user) 44 | |> assign(:organizations, organizations) 45 | |> assign(:is_current_user, is_current_user)} 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/atomic_web/live/scanner_live/index.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.ScannerLive.Index do 2 | @moduledoc false 3 | use AtomicWeb, :live_view 4 | 5 | alias Atomic.Accounts 6 | alias Atomic.Activities 7 | 8 | @impl true 9 | def mount(_params, _session, socket) do 10 | {:ok, socket} 11 | end 12 | 13 | @impl true 14 | def handle_params(_params, _, socket) do 15 | {:noreply, 16 | socket 17 | |> assign(:current_page, :scanner) 18 | |> assign(:title, "Scanner")} 19 | end 20 | 21 | @doc """ 22 | Handles the scan event. 23 | """ 24 | @impl true 25 | def handle_event("scan", pathname, socket) do 26 | [_, activity_id, user_id | _] = String.split(pathname, "/") 27 | activity = Activities.get_activity!(activity_id) 28 | 29 | if (socket.assigns.current_organization.id == activity.organization_id && 30 | Accounts.has_permissions_inside_organization?( 31 | socket.assigns.current_user.id, 32 | socket.assigns.current_organization.id 33 | )) || Accounts.has_master_permissions?(socket.assigns.current_user) do 34 | confirm_participation(socket, activity_id, user_id) 35 | else 36 | {:noreply, 37 | socket 38 | |> put_flash(:error, "You are not authorized to this") 39 | |> redirect(to: ~p"/scanner")} 40 | end 41 | end 42 | 43 | defp confirm_participation(socket, session_id, user_id) do 44 | case Activities.update_enrollment(Activities.get_enrollment!(session_id, user_id), %{ 45 | present: true 46 | }) do 47 | {:ok, _} -> 48 | {:noreply, 49 | socket 50 | |> put_flash(:success, "Participation confirmed!") 51 | |> assign(:changeset, nil) 52 | |> redirect(to: ~p"/scanner")} 53 | 54 | {:error, changeset} -> 55 | {:noreply, 56 | socket 57 | |> put_flash(:error, "Unable to confirm participation") 58 | |> assign(:changeset, changeset)} 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/atomic_web/live/scanner_live/index.html.heex: -------------------------------------------------------------------------------- 1 |
2 |

3 | Scan a QR code 4 |

5 | 6 |
7 |
8 | 9 |
10 |
11 | Unable to access camera. Make sure you allow the use of the camera and that the camera isn't being used elsewhere. 12 |
13 | 14 |
15 |
16 |
17 | -------------------------------------------------------------------------------- /lib/atomic_web/live/user_live/edit.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.UserLive.Edit do 2 | use AtomicWeb, :live_view 3 | 4 | import AtomicWeb.LiveHelpers 5 | 6 | alias Atomic.Accounts 7 | 8 | @impl true 9 | def mount(_params, _session, socket) do 10 | {:ok, assign(socket, :current_user, socket.assigns.current_user)} 11 | end 12 | 13 | @impl true 14 | def handle_params(_, _, socket) do 15 | {:noreply, 16 | socket 17 | |> assign(:page_title, "Edit Account") 18 | |> assign_page_metadata(:edit_account) 19 | |> assign(:user, socket.assigns.current_user) 20 | |> assign( 21 | :courses, 22 | Enum.map(Accounts.list_courses(), fn m -> [key: m.name, value: m.id] end) 23 | )} 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/atomic_web/live/user_live/edit.html.heex: -------------------------------------------------------------------------------- 1 | <.live_component module={AtomicWeb.UserLive.FormComponent} user={@user} id={@current_user.id} courses={@courses} title={@page_title} action={@live_action} return_to={~p"/"} /> 2 | -------------------------------------------------------------------------------- /lib/atomic_web/live/user_live/form_component.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.UserLive.FormComponent do 2 | use AtomicWeb, :live_component 3 | 4 | alias Atomic.Accounts 5 | 6 | @impl true 7 | def update(%{user: user} = assigns, socket) do 8 | changeset = Accounts.update_user(user) 9 | 10 | {:ok, 11 | socket 12 | |> assign(assigns) 13 | |> assign(:changeset, changeset) 14 | |> allow_upload(:profile_picture, accept: ~w(.jpg .jpeg .png), max_entries: 1)} 15 | end 16 | 17 | @impl true 18 | def handle_event("validate", %{"user" => _user_params}, socket) do 19 | {:noreply, socket} 20 | end 21 | 22 | @impl true 23 | def handle_event("save", %{"user" => user_params}, socket) do 24 | update_user( 25 | socket, 26 | socket.assigns.action, 27 | user_params 28 | ) 29 | end 30 | 31 | defp update_user(socket, :edit, user_params) do 32 | user = socket.assigns.user 33 | 34 | consume_uploaded_entries(socket, :profile_picture, fn %{path: path}, entry -> 35 | Accounts.update_user_picture(user, %{ 36 | "profile_picture" => %Plug.Upload{ 37 | content_type: entry.client_type, 38 | filename: entry.client_name, 39 | path: path 40 | } 41 | }) 42 | end) 43 | 44 | case Accounts.update_user(user, user_params) do 45 | {:ok, _user} -> 46 | {:noreply, 47 | socket 48 | |> put_flash(:success, "User updated successfully") 49 | |> push_navigate(to: socket.assigns.return_to)} 50 | 51 | {:error, %Ecto.Changeset{} = changeset} -> 52 | {:noreply, assign(socket, :changeset, changeset)} 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/atomic_web/live/user_live/form_component.html.heex: -------------------------------------------------------------------------------- 1 |
2 |

{@title}

3 | 4 | <.form :let={f} for={@changeset} id="user-form" phx-change="validate" phx-target={@myself} phx-submit="save"> 5 | {label(f, :name)} 6 | {text_input(f, :name)} 7 | {label(f, :course_id)} 8 | {select(f, :course_id, @courses)} 9 | {label(f, :profile_picture)} 10 | <.live_file_input upload={@uploads.profile_picture} /> 11 | {submit("Save", phx_disable_with: "Saving...")} 12 | 13 |
14 | -------------------------------------------------------------------------------- /lib/atomic_web/plugs/authorize.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.Plugs.Authorize do 2 | @moduledoc """ 3 | This plug is used to authorize users to access certain parts of the application. 4 | """ 5 | import Plug.Conn 6 | 7 | alias Atomic.Organizations 8 | 9 | def init(opts), do: opts 10 | 11 | def call(conn, :master = role) do 12 | if conn.assigns.current_user.role == role do 13 | conn 14 | else 15 | conn 16 | |> send_resp(:not_found, "") 17 | |> halt() 18 | end 19 | end 20 | 21 | def call(conn, minimum_authorized_role) do 22 | if authorized?(conn, minimum_authorized_role) do 23 | conn 24 | else 25 | conn 26 | |> send_resp(:not_found, "") 27 | |> halt() 28 | end 29 | end 30 | 31 | defp authorized?(conn, minimum_authorized_role) do 32 | organization_id = get_organization_id(conn) 33 | 34 | case {organization_id, conn.assigns.current_user} do 35 | {nil, _} -> 36 | false 37 | 38 | {id, user} -> 39 | user_can_manage_organization?(user, id, minimum_authorized_role) 40 | end 41 | end 42 | 43 | defp user_can_manage_organization?(user, organization_id, minimum_authorized_role) do 44 | user_organizations = Enum.map(user.organizations, & &1.id) 45 | role = Organizations.get_role(user.id, organization_id) 46 | allowed_roles = Organizations.roles_bigger_than_or_equal(minimum_authorized_role) 47 | 48 | (organization_id in user_organizations && role in allowed_roles) || user.role == :master 49 | end 50 | 51 | defp get_organization_id(conn) do 52 | case conn.params["organization_id"] do 53 | organization_id when is_binary(organization_id) -> 54 | organization_id 55 | 56 | _ -> 57 | nil 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/atomic_web/plugs/verify_association.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.Plugs.VerifyAssociation do 2 | @moduledoc """ 3 | This plug is used to confirm if the object being accessed has an association with the organization in the connection parameters. 4 | """ 5 | import Plug.Conn 6 | 7 | def init(opts), do: opts 8 | 9 | def call(conn, fun) do 10 | case conn.params["id"] do 11 | id when is_binary(id) -> 12 | case fun.(id) do 13 | nil -> 14 | conn 15 | |> send_resp(:not_found, "") 16 | |> halt() 17 | 18 | entity -> 19 | verify_association(entity, conn) 20 | end 21 | 22 | _ -> 23 | conn 24 | end 25 | end 26 | 27 | defp verify_association(entity, conn) do 28 | if has_relation?(entity, conn.params["organization_id"]) do 29 | conn 30 | else 31 | conn 32 | |> send_resp(:not_found, "") 33 | |> halt() 34 | end 35 | end 36 | 37 | defp has_relation?(_, organization_id) when is_nil(organization_id), do: false 38 | 39 | defp has_relation?(entity, organization_id) do 40 | entity.organization_id == organization_id 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/atomic_web/storybook.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.Storybook do 2 | @moduledoc """ 3 | Storybook configuration for Atomic 4 | """ 5 | 6 | use PhoenixStorybook, 7 | otp_app: :atomic_web, 8 | content_path: Path.expand("../../storybook", __DIR__), 9 | # assets path are remote path, not local file-system paths 10 | css_path: "/assets/storybook.css", 11 | js_path: "/assets/storybook.js", 12 | sandbox_class: "atomic-web" 13 | end 14 | -------------------------------------------------------------------------------- /lib/atomic_web/templates/error/404.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <.live_title> 9 | {assigns[:page_title] || "Atomic"} 10 | 11 | 12 | 13 |
14 |
15 |
16 | Error 17 | 404 18 |
19 |

Page Not Found

20 | <.link href="/" class="bg-primary-500 my-4 inline-flex items-center rounded-md px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-primary-600 focus-visible:outline-primary-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"> 21 | {gettext("Go back home")} 22 | 23 |
24 |
25 | 26 | -------------------------------------------------------------------------------- /lib/atomic_web/templates/error/500.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <.live_title> 9 | {assigns[:page_title] || "Atomic"} 10 | 11 | 12 | 13 | 14 |
15 |
16 |

500

17 |

Internal Server Error

18 |

Oops! It appears that something went wrong on our end.

19 |
20 | Go back home 21 |
22 |
23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /lib/atomic_web/templates/layout/_live_navbar.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 3 | 6 | 7 |
8 | 9 |
10 |
11 | -------------------------------------------------------------------------------- /lib/atomic_web/templates/layout/_user_menu.html.heex: -------------------------------------------------------------------------------- 1 | <%= if @current_user do %> 2 |
  • {@current_user.email}
  • 3 |
  • {link("Log out", to: ~p"/users/log_out", method: :delete)}
  • 4 | <% else %> 5 |
  • {link("Register", to: ~p"/users/register")}
  • 6 |
  • {link("Log in", to: ~p"/users/log_in")}
  • 7 | <% end %> 8 | -------------------------------------------------------------------------------- /lib/atomic_web/templates/layout/app.html.heex: -------------------------------------------------------------------------------- 1 |
    2 | <%= if Phoenix.Flash.get(@flash, :info) do %> 3 |
    4 |
    5 |
    6 |
    7 |
    8 |
    9 | <.icon name="hero-information-circle-solid" class="h-6 w-6 text-blue-400" /> 10 |
    11 |
    12 | {Phoenix.Flash.get(@flash, :info)} 13 |
    14 |
    15 |
    16 |
    17 |
    18 | <% end %> 19 | 20 | <%= if Phoenix.Flash.get(@flash, :error) do %> 21 |
    22 |
    23 |
    24 |
    25 |
    26 |
    27 | <.icon name="hero-x-circle-solid" class="h-6 w-6 text-red-400" /> 28 |
    29 |
    30 | {Phoenix.Flash.get(@flash, :error)} 31 |
    32 |
    33 |
    34 |
    35 |
    36 | <% end %> 37 | 38 | {@inner_content} 39 |
    40 | -------------------------------------------------------------------------------- /lib/atomic_web/templates/layout/live.html.heex: -------------------------------------------------------------------------------- 1 | 2 |
    3 | <%= for {key, message} <- @flash do %> 4 | <.live_component id={key} module={AtomicWeb.Components.Notification} type={key} message={message} flash={@flash} /> 5 | <% end %> 6 |
    7 | 8 |
    9 |
    10 | 11 |
    12 | {render("_live_navbar.html", assigns)} 13 |
    14 | 15 |
    16 | {@inner_content} 17 |
    18 |
    19 |
    20 | -------------------------------------------------------------------------------- /lib/atomic_web/templates/layout/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {csrf_meta_tag()} 10 | <.live_title> 11 | {assigns[:page_title] || "Atomic"} 12 | 13 | 14 | 16 | 17 | 18 | {@inner_content} 19 | 20 | 21 | -------------------------------------------------------------------------------- /lib/atomic_web/templates/user_confirmation/new.html.heex: -------------------------------------------------------------------------------- 1 |

    Resend confirmation instructions

    2 | 3 | <.form :let={f} for={%{}} as={:user} action={~p"/users/confirm"}> 4 | {label(f, :email)} 5 | {email_input(f, :email, required: true)} 6 | 7 |
    8 | {submit("Resend confirmation instructions")} 9 |
    10 | 11 | 12 |

    13 | {link("Register", to: ~p"/users/register")} | {link("Log in", to: ~p"/users/log_in")} 14 |

    15 | -------------------------------------------------------------------------------- /lib/atomic_web/views/email_view.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.EmailView do 2 | use AtomicWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/atomic_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | import Phoenix.HTML.Form 7 | use PhoenixHTMLHelpers 8 | 9 | @doc """ 10 | Generates tag for inlined form input errors. 11 | """ 12 | def error_tag(form, field) do 13 | Enum.map(Keyword.get_values(form.errors, field), fn error -> 14 | content_tag(:span, translate_error(error), 15 | class: "invalid-feedback", 16 | phx_feedback_for: input_name(form, field) 17 | ) 18 | end) 19 | end 20 | 21 | @doc """ 22 | Translates an error message using gettext. 23 | """ 24 | def translate_error({msg, opts}) do 25 | # When using gettext, we typically pass the strings we want 26 | # to translate as a static argument: 27 | # 28 | # # Translate "is invalid" in the "errors" domain 29 | # dgettext("errors", "is invalid") 30 | # 31 | # # Translate the number of files with plural rules 32 | # dngettext("errors", "1 file", "%{count} files", count) 33 | # 34 | # Because the error messages we show in our forms and APIs 35 | # are defined inside Ecto, we need to translate them dynamically. 36 | # This requires us to call the Gettext module passing our gettext 37 | # backend as first argument. 38 | # 39 | # Note we use the "errors" domain, which means translations 40 | # should be written to the errors.po file. The :count option is 41 | # set by Ecto and indicates we should also apply plural rules. 42 | if count = opts[:count] do 43 | Gettext.dngettext(AtomicWeb.Gettext, "errors", msg, msg, count, opts) 44 | else 45 | Gettext.dgettext(AtomicWeb.Gettext, "errors", msg, opts) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/atomic_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.ErrorView do 2 | use AtomicWeb, :view 3 | 4 | # If you want to customize a particular status code 5 | # for a certain format, you may uncomment below. 6 | # def render("500.html", _assigns) do 7 | # "Internal Server Error" 8 | # end 9 | 10 | # By default, Phoenix returns the status message from 11 | # the template name. For example, "404.html" becomes 12 | # "Not Found". 13 | def template_not_found(template, _assigns) do 14 | Phoenix.Controller.status_message_from_template(template) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/atomic_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.LayoutView do 2 | use AtomicWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/atomic_web/views/pdf_view.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.PDFView do 2 | use AtomicWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/atomic_web/views/user_change_password_view.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.UserChangePasswordView do 2 | use AtomicWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/atomic_web/views/user_confirmation_view.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.UserConfirmationView do 2 | use AtomicWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/atomic_web/views/user_reset_password_view.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.UserResetPasswordView do 2 | use AtomicWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/atomic_web/views/user_setup_view.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.UserSetupView do 2 | use AtomicWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /linux.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | network_mode: "host" 4 | web: 5 | network_mode: "host" 6 | -------------------------------------------------------------------------------- /priv/fake/masters.txt: -------------------------------------------------------------------------------- 1 | Chandler Bing 2 | Monica Geller 3 | Ross Geller 4 | Joey Tribbiani 5 | Rachel Green 6 | Phoebe Buffay -------------------------------------------------------------------------------- /priv/fake/students.txt: -------------------------------------------------------------------------------- 1 | Aberforth Dumbledore 2 | Adrian Mole 3 | Albus Dumbledore 4 | Albus Sverus Potter 5 | Amycus Carrow 6 | Anne Frank 7 | Anne of Green Gables 8 | Argus Filch 9 | Asterix Obelix 10 | Calvin and Hobbes 11 | Charity Burbage 12 | Charlie Brown 13 | Corto Maltese 14 | Curious George 15 | Dudley Dursley 16 | Elphias Doge 17 | Ernie Macmillan 18 | Filius Fitwick 19 | Fleur Delacour 20 | Gabrielle Delacour 21 | George Weasley 22 | Geronimo Stilton 23 | Gilderoy Lockhart 24 | Greg Heffley 25 | Gregory Goyle 26 | Hannah Abott 27 | Harry Potter 28 | Heidi and Marco 29 | Helena Ravenclaw 30 | Hermione Ganger 31 | Horce Slughorn 32 | Huckleberry Finn 33 | Hungry Catterpilar 34 | James Potter 35 | Katie Bell 36 | King Babar 37 | Lilly Evans Potter 38 | Lily Luna Potter 39 | Little Prince 40 | Lucious Malfoy 41 | Lucky Luck 42 | Luna Lovegood 43 | Mafalda Quino 44 | Malala Malala 45 | Marry Cattermole 46 | Michael Corner 47 | Molly Weasley 48 | Nancy Drew 49 | Narcissa Malfoy 50 | Neville Longbottom 51 | Nymphadora Tonks 52 | Padington Bear 53 | Padma Patil 54 | Pansy Parkinson 55 | Peppa Pig 56 | Percy Weasley 57 | Peter Rabbit 58 | Petunia Evans Dursley 59 | Pippi Longstocking 60 | Pomona Sprout 61 | Reginald Cattermole 62 | Reginald Coner 63 | Remus Lupin 64 | Rita Skeeter 65 | Rubeus Hagrid 66 | Rufus Scrimgeour 67 | Rupert Bear 68 | Scooby Doo 69 | Serlock Holmes 70 | Snoopy Dog 71 | Stuart Little 72 | Ted Tonks 73 | Throwfinn Rowle 74 | Tintin Herge 75 | Tom Sawyer 76 | Vernon Dursley 77 | Viktor Krum 78 | Vincent Crabbe 79 | Winnie de Pooh -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /priv/repo/migrations/2022000000000_create_organizations.exs: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Repo.Migrations.CreateOrganizations do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:organizations, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | 8 | add :name, :string, null: false 9 | add :long_name, :string, null: false 10 | add :description, :text, null: false 11 | 12 | add :logo, :string 13 | add :location, :map 14 | 15 | timestamps() 16 | end 17 | 18 | create unique_index(:organizations, [:name]) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /priv/repo/migrations/20221000000000_create_departments.exs: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Repo.Migrations.CreateDepartments do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:departments, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | 8 | add :name, :string, null: false 9 | add :description, :text 10 | 11 | add :collaborator_applications, :boolean, default: false, null: false 12 | add :archived, :boolean, default: false, null: false 13 | 14 | add :banner, :string 15 | 16 | add :organization_id, references(:organizations, on_delete: :delete_all, type: :binary_id), 17 | null: false 18 | 19 | timestamps() 20 | end 21 | 22 | create index(:departments, [:organization_id]) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /priv/repo/migrations/20221014155230_create_users_auth_tables.exs: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Repo.Migrations.CreateUsersAuthTables do 2 | use Ecto.Migration 3 | 4 | def change do 5 | execute "CREATE EXTENSION IF NOT EXISTS citext", "" 6 | 7 | create table(:users, primary_key: false) do 8 | add :id, :binary_id, primary_key: true 9 | 10 | add :name, :string 11 | add :email, :citext, null: false 12 | add :slug, :citext 13 | add :role, :string, null: false, default: "student" 14 | 15 | add :socials, :map 16 | 17 | add :hashed_password, :string, null: false 18 | 19 | add :confirmed_at, :naive_datetime 20 | add :phone_number, :string 21 | add :profile_picture, :string 22 | add :banner, :string 23 | 24 | add :current_organization_id, 25 | references(:organizations, type: :binary_id, on_delete: :delete_all) 26 | 27 | timestamps() 28 | end 29 | 30 | create unique_index(:users, [:email]) 31 | create unique_index(:users, [:slug]) 32 | 33 | create table(:users_tokens, primary_key: false) do 34 | add :id, :binary_id, primary_key: true 35 | 36 | add :token, :binary, null: false 37 | add :context, :string, null: false 38 | add :sent_to, :string 39 | 40 | add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false 41 | 42 | timestamps(updated_at: false) 43 | end 44 | 45 | create index(:users_tokens, [:user_id]) 46 | create unique_index(:users_tokens, [:context, :token]) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /priv/repo/migrations/20221022010100_create_activities.exs: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Repo.Migrations.CreateActivities do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:activities, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | 8 | add :title, :string, null: false 9 | add :description, :text, null: false 10 | 11 | add :start, :naive_datetime, null: false 12 | add :finish, :naive_datetime, null: false 13 | 14 | add :minimum_entries, :integer, null: false 15 | add :maximum_entries, :integer, null: false 16 | add :enrolled, :integer, default: 0, null: false 17 | 18 | add :image, :string 19 | add :location, :map 20 | 21 | add :organization_id, references(:organizations, type: :binary_id), null: false 22 | 23 | timestamps() 24 | end 25 | 26 | create constraint(:activities, :enrolled_less_than_max, check: "enrolled <= maximum_entries") 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /priv/repo/migrations/20221104160002_create_enrollments.exs: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Repo.Migrations.CreateEnrollments do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:enrollments, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | 8 | add :present, :boolean, null: false, default: false 9 | 10 | add :activity_id, references(:activities, on_delete: :delete_all, type: :binary_id) 11 | add :user_id, references(:users, type: :binary_id) 12 | 13 | timestamps() 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /priv/repo/migrations/20221123000537_create_partners.exs: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Repo.Migrations.CreatePartners do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:partners, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | 8 | add :name, :string, null: false 9 | add :description, :text 10 | add :notes, :text 11 | 12 | add :benefits, :text 13 | add :archived, :boolean, default: false 14 | add :image, :string 15 | 16 | add :location, :map 17 | add :socials, :map 18 | 19 | add :organization_id, references(:organizations, on_delete: :delete_all, type: :binary_id) 20 | 21 | timestamps() 22 | end 23 | 24 | create index(:partners, [:organization_id]) 25 | create unique_index(:partners, [:name]) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230313102641_create_memberships.exs: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Repo.Migrations.CreateMemberships do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:memberships, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | 8 | add :role, :string, null: false 9 | 10 | add :user_id, references(:users, type: :binary_id), null: false 11 | add :organization_id, references(:organizations, type: :binary_id), null: false 12 | 13 | timestamps() 14 | end 15 | 16 | create unique_index(:memberships, [:user_id, :organization_id]) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230325151547_create_courses.exs: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Repo.Migrations.CreateCourses do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:courses, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | 8 | add :name, :string, null: false 9 | add :cycle, :string, null: false 10 | 11 | timestamps() 12 | end 13 | 14 | alter table(:users) do 15 | add :course_id, references(:courses, type: :binary_id) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230830011755_create_announcements.exs: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Repo.Migrations.CreateAnnouncements do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:announcements, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | add :title, :string, null: false 8 | add :description, :text, null: false 9 | add :image, :string 10 | 11 | add :organization_id, references(:organizations, on_delete: :nothing, type: :binary_id), 12 | null: false 13 | 14 | timestamps() 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230880102641_create_collaborators.exs: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Repo.Migrations.CreateCollaborators do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:collaborators, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | add :accepted, :boolean, default: false 8 | add :accepted_at, :naive_datetime 9 | 10 | add :user_id, references(:users, on_delete: :nothing, type: :binary_id), null: false 11 | 12 | add :department_id, references(:departments, on_delete: :delete_all, type: :binary_id), 13 | null: false 14 | 15 | timestamps() 16 | end 17 | 18 | create unique_index(:collaborators, [:user_id, :department_id]) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /priv/repo/migrations/20231111204142_create_posts.exs: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Repo.Migrations.CreatePosts do 2 | @moduledoc false 3 | use Ecto.Migration 4 | 5 | def change do 6 | create table(:posts, primary_key: false) do 7 | add :id, :binary_id, primary_key: true 8 | 9 | add :type, :string, null: false 10 | 11 | timestamps() 12 | end 13 | 14 | create index(:posts, [:inserted_at, :id]) 15 | 16 | alter table(:activities) do 17 | add :post_id, references(:posts, type: :binary_id), null: false 18 | end 19 | 20 | alter table(:announcements) do 21 | add :post_id, references(:posts, type: :binary_id), null: false 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Repo.Seeds do 2 | @moduledoc """ 3 | Script for populating the database. 4 | You can run it as: 5 | $ mix run priv/repo/seeds.exs # or mix ecto.seed 6 | """ 7 | @seeds_dir "priv/repo/seeds" 8 | 9 | def run do 10 | [ 11 | "organizations.exs", 12 | "courses.exs", 13 | "accounts.exs", 14 | "feed.exs", 15 | "enrollments.exs", 16 | "departments.exs", 17 | "memberships.exs", 18 | "partners.exs" 19 | ] 20 | |> Enum.each(fn file -> 21 | Code.require_file("#{@seeds_dir}/#{file}") 22 | end) 23 | end 24 | end 25 | 26 | Atomic.Repo.Seeds.run() 27 | -------------------------------------------------------------------------------- /priv/repo/seeds/accounts.exs: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Repo.Seeds.Accounts do 2 | @moduledoc """ 3 | Seeds the database with users. 4 | """ 5 | alias Atomic.Accounts 6 | alias Atomic.Accounts.{Course, User} 7 | alias Atomic.Organizations.Organization 8 | alias Atomic.Repo 9 | 10 | @masters File.read!("priv/fake/masters.txt") |> String.split("\n") 11 | @students File.read!("priv/fake/students.txt") |> String.split("\n") 12 | 13 | def run do 14 | case Repo.all(User) do 15 | [] -> 16 | seed_users(@masters, :master) 17 | seed_users(@students, :student) 18 | 19 | _ -> 20 | Mix.shell().error("Found users, aborting seeding users.") 21 | end 22 | end 23 | 24 | def seed_users(characters, role) do 25 | courses = Repo.all(Course) 26 | organizations = Repo.all(Organization) 27 | 28 | for character <- characters do 29 | email = (character |> String.downcase() |> String.replace(~r/\s*/, "")) <> "@mail.pt" 30 | slug = character |> String.downcase() |> String.replace(~r/\s/, "_") 31 | 32 | phone_number = 33 | "+3519#{Enum.random([1, 2, 3, 6])}#{for _ <- 1..7, do: Enum.random(0..9) |> Integer.to_string()}" 34 | 35 | user = %{ 36 | "name" => character, 37 | "email" => email, 38 | "slug" => slug, 39 | "phone_number" => phone_number, 40 | "password" => "password1234", 41 | "role" => role, 42 | "course_id" => Enum.random(courses).id, 43 | "current_organization_id" => Enum.random(organizations).id 44 | } 45 | 46 | case Accounts.register_user(user) do 47 | {:ok, changeset} -> 48 | Repo.update!(Accounts.User.confirm_changeset(changeset)) 49 | 50 | {:error, changeset} -> 51 | Mix.shell().error(Kernel.inspect(changeset.errors)) 52 | end 53 | end 54 | end 55 | end 56 | 57 | Atomic.Repo.Seeds.Accounts.run() 58 | -------------------------------------------------------------------------------- /priv/repo/seeds/courses.exs: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Repo.Seeds.Courses do 2 | @moduledoc """ 3 | Seeds the database with courses. 4 | """ 5 | alias Atomic.Accounts 6 | alias Atomic.Accounts.Course 7 | alias Atomic.Repo 8 | 9 | @courses File.read!("data/courses.txt") |> String.split("\n") 10 | @cycles ~w(Bachelors Masters PhD)a 11 | 12 | def run do 13 | case Repo.all(Course) do 14 | [] -> 15 | seed_courses() 16 | 17 | _ -> 18 | Mix.shell().error("Found courses, aborting seeding courses.") 19 | end 20 | end 21 | 22 | def seed_courses do 23 | @courses 24 | |> Enum.each(fn course -> 25 | %{ 26 | name: course, 27 | cycle: Enum.random(@cycles) 28 | } 29 | |> Accounts.create_course() 30 | end) 31 | end 32 | end 33 | 34 | Atomic.Repo.Seeds.Courses.run() 35 | -------------------------------------------------------------------------------- /priv/repo/seeds/enrollments.exs: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Repo.Seeds.Enrollments do 2 | @moduledoc """ 3 | Seeds the database with enrollments. 4 | """ 5 | alias Atomic.Accounts.User 6 | alias Atomic.Activities 7 | alias Atomic.Activities.{Activity, Enrollment} 8 | alias Atomic.Repo 9 | 10 | def run do 11 | seed_enrollments() 12 | end 13 | 14 | def seed_enrollments do 15 | case Repo.all(Enrollment) do 16 | [] -> 17 | users = Repo.all(User) 18 | activities = Repo.all(Activity) 19 | 20 | for user <- users do 21 | for _ <- 1..Enum.random(1..2) do 22 | Activities.create_enrollment( 23 | Enum.random(activities).id, 24 | user 25 | ) 26 | end 27 | end 28 | 29 | _ -> 30 | Mix.shell().error("Found enrollments, aborting seeding enrollments.") 31 | end 32 | end 33 | end 34 | 35 | Atomic.Repo.Seeds.Enrollments.run() 36 | -------------------------------------------------------------------------------- /priv/repo/seeds/memberships.exs: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Repo.Seeds.Memberships do 2 | @moduledoc """ 3 | Seeds the database with memberships. 4 | """ 5 | alias Atomic.Accounts.User 6 | alias Atomic.Organization 7 | alias Atomic.Organizations.{Membership, Organization} 8 | alias Atomic.Repo 9 | 10 | @roles Membership.roles() 11 | 12 | def run do 13 | seed_memberships() 14 | end 15 | 16 | def seed_memberships do 17 | case Repo.all(Membership) do 18 | [] -> 19 | users = Repo.all(User) 20 | organizations = Repo.all(Organization) 21 | 22 | for user <- users do 23 | random_number = :rand.uniform(100) 24 | 25 | # 50% chance of having a membership 26 | if random_number < 50 do 27 | %Membership{} 28 | |> Membership.changeset(%{ 29 | "user_id" => user.id, 30 | "organization_id" => Enum.random(organizations).id, 31 | "role" => Enum.random(@roles) 32 | }) 33 | |> Repo.insert!() 34 | end 35 | end 36 | 37 | _ -> 38 | Mix.shell().error("Found memberships, aborting seeding memberships.") 39 | end 40 | end 41 | end 42 | 43 | Atomic.Repo.Seeds.Memberships.run() 44 | -------------------------------------------------------------------------------- /priv/repo/seeds/partners.exs: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Repo.Seeds.Partners do 2 | @moduledoc """ 3 | Seeds the database with partners. 4 | """ 5 | alias Atomic.Organizations.{Organization, Partner} 6 | alias Atomic.Repo 7 | 8 | def run do 9 | case Repo.all(Partner) do 10 | [] -> 11 | seed_partners() 12 | 13 | _ -> 14 | Mix.shell().error("Found partners, aborting seeding partners.") 15 | end 16 | end 17 | 18 | def seed_partners do 19 | organizations = Repo.all(Organization) 20 | 21 | location = %{ 22 | name: Faker.Address.city(), 23 | url: Faker.Internet.url() 24 | } 25 | 26 | socials = %{ 27 | instagram: Faker.Internet.slug(), 28 | facebook: Faker.Internet.slug(), 29 | x: Faker.Internet.slug(), 30 | youtube: Faker.Internet.slug(), 31 | tiktok: Faker.Internet.slug(), 32 | website: Faker.Internet.url() 33 | } 34 | 35 | for {organization, i} <- Enum.with_index(organizations) do 36 | for _ <- 0..5 do 37 | %Partner{} 38 | |> Partner.changeset(%{ 39 | name: Faker.Company.name() <> " " <> Integer.to_string(i), 40 | description: Enum.join(Faker.Lorem.paragraphs(2), "\n"), 41 | benefits: Enum.join(Faker.Lorem.paragraphs(5), "\n"), 42 | organization_id: organization.id, 43 | location: location, 44 | socials: socials 45 | }) 46 | |> Repo.insert!() 47 | end 48 | end 49 | end 50 | end 51 | 52 | Atomic.Repo.Seeds.Partners.run() 53 | -------------------------------------------------------------------------------- /priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cesium/atomic/3456c7fce5cca238cc837676fc840aafcfd06089/priv/static/favicon.ico -------------------------------------------------------------------------------- /priv/static/images/backgrounds/0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cesium/atomic/3456c7fce5cca238cc837676fc840aafcfd06089/priv/static/images/backgrounds/0.png -------------------------------------------------------------------------------- /priv/static/images/cesium-ORANGE.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | CeSIUM 10 | 14 | 18 | 22 | 26 | 30 | 34 | 39 | 43 | 47 | 48 | -------------------------------------------------------------------------------- /priv/static/images/facebook.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /priv/static/images/instagram.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /priv/static/images/pitch/0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cesium/atomic/3456c7fce5cca238cc837676fc840aafcfd06089/priv/static/images/pitch/0.png -------------------------------------------------------------------------------- /priv/static/images/pitch/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cesium/atomic/3456c7fce5cca238cc837676fc840aafcfd06089/priv/static/images/pitch/1.png -------------------------------------------------------------------------------- /priv/static/images/tiktok.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /priv/static/images/x.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /priv/static/images/youtube.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | # Admin routes 3 | Disallow: /organizations/new 4 | Disallow: /organizations/*/edit 5 | Disallow: /activities/*/edit 6 | Disallow: /announcements/*/edit 7 | Disallow: /departments/*/edit 8 | Disallow: /partners/*/edit 9 | 10 | # Authentication and account management 11 | Disallow: /users/ 12 | Disallow: /users/register 13 | Disallow: /users/log_in 14 | Disallow: /users/log_out 15 | Disallow: /users/reset_password 16 | Disallow: /users/change_password 17 | Disallow: /users/setup 18 | Disallow: /users/confirm 19 | Disallow: /users/confirm_email/* 20 | 21 | # Tools 22 | Disallow: /scanner/ 23 | Disallow: /storybook/ 24 | Disallow: /dev/ 25 | Disallow: /dashboard/ 26 | Disallow: /mailbox/ 27 | 28 | Disallow: /*?* 29 | Allow: / 30 | Sitemap: https://atomic.cesium.pt/sitemap.xml 31 | Crawl-delay: 10 -------------------------------------------------------------------------------- /scripts/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright © 2021 Nelson Estevão 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the “Software”), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /scripts/colors.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -n 2 | 3 | # shellcheck disable=SC2034 4 | RED=$(tput setaf 1) 5 | # shellcheck disable=SC2034 6 | GREEN=$(tput setaf 2) 7 | # shellcheck disable=SC2034 8 | YELLOW=$(tput setaf 3) 9 | # shellcheck disable=SC2034 10 | CYAN=$(tput setaf 4) 11 | # shellcheck disable=SC2034 12 | PURPLE=$(tput setaf 5) 13 | # shellcheck disable=SC2034 14 | BLUE=$(tput setaf 6) 15 | # shellcheck disable=SC2034 16 | WHITE=$(tput setaf 7) 17 | # shellcheck disable=SC2034 18 | BOLD=$(tput bold) 19 | # shellcheck disable=SC2034 20 | UNDERLINE=$(tput smul) 21 | # shellcheck disable=SC2034 22 | REVERSE=$(tput rev) 23 | # shellcheck disable=SC2034 24 | RESET=$(tput sgr0) 25 | -------------------------------------------------------------------------------- /scripts/git.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -Eeuo pipefail 4 | 5 | SCRIPT_DIR=$(dirname "${BASH_SOURCE[0]:-$0}") 6 | 7 | . "${SCRIPT_DIR}/helpers.sh" 8 | 9 | function get_default_repo_branch() { 10 | # Check if main exists and use instead of master 11 | if command git rev-parse --git-dir &>/dev/null; then 12 | for ref in refs/{heads,remotes/{origin,upstream}}/{main,trunk,mainline,default}; do 13 | if command git show-ref -q --verify "$ref"; then 14 | echo "${ref##*/}" 15 | return 16 | fi 17 | done 18 | fi 19 | echo master 20 | } 21 | 22 | ([ "$0" = "${BASH_SOURCE[0]}" ] && display_version 0.14.0) || true 23 | 24 | default_branch=$(get_default_repo_branch) 25 | echo "The default repo branch is ${default_branch}" 26 | -------------------------------------------------------------------------------- /scripts/helpers.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -Eeuo pipefail 4 | 5 | import() { 6 | local -r SCRIPTS_DIR=$(dirname "${BASH_SOURCE[0]:-$0}") 7 | 8 | # shellcheck source=/dev/null 9 | . "${SCRIPTS_DIR}/${1}" 10 | } 11 | 12 | # shellcheck source=./colors.sh 13 | import colors.sh 14 | 15 | function display_version() { 16 | local program="${2:-$(basename "$0")}" 17 | local version=${1:?"You need to give a version number"} 18 | 19 | if [ -x "$(command -v figlet)" ]; then 20 | echo -n "${BLUE}${BOLD}" 21 | figlet "${program} script" 22 | echo -n "${RESET}" 23 | echo "version ${version}" 24 | else 25 | echo "${program} script version ${version}" 26 | fi 27 | } 28 | 29 | function help_title_section() { 30 | local -r TITLE=$(echo "$@" | tr '[:lower:]' '[:upper:]') 31 | echo -e "${BOLD}${TITLE}${RESET}" 32 | } 33 | 34 | ([ "$0" = "${BASH_SOURCE[0]}" ] && display_version 0.14.0) || true 35 | -------------------------------------------------------------------------------- /storybook/_root.index.exs: -------------------------------------------------------------------------------- 1 | defmodule Storybook.Root do 2 | # See https://hexdocs.pm/phoenix_storybook/PhoenixStorybook.Index.html for full index 3 | # documentation. 4 | 5 | use PhoenixStorybook.Index 6 | 7 | def folder_icon, do: {:fa, "book-open", :light, "lsb-mr-1"} 8 | def folder_name, do: "Atomic" 9 | 10 | def entry("begin") do 11 | [ 12 | name: "Welcome", 13 | icon: {:fa, "hand-wave", :thin} 14 | ] 15 | end 16 | 17 | def entry("icons") do 18 | [ 19 | name: "Icons List", 20 | icon: {:fa, "icons", :thin} 21 | ] 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /storybook/begin.story.exs: -------------------------------------------------------------------------------- 1 | defmodule Storybook.Begin do 2 | use PhoenixStorybook.Story, :page 3 | 4 | def doc, do: "Set of reusable components used in the Atomic platform by CeSIUM." 5 | 6 | def render(assigns) do 7 | ~H""" 8 |
    9 |
    10 | 11 |

    Atomic

    12 |
    13 |
    14 | """ 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /storybook/components/_components.index.exs: -------------------------------------------------------------------------------- 1 | defmodule Storybook.Components do 2 | use PhoenixStorybook.Index 3 | 4 | def folder_icon, do: {:fa, "toolbox", :light, "lsb-mr-1"} 5 | def folder_name, do: "Components" 6 | def folder_open?, do: true 7 | end 8 | -------------------------------------------------------------------------------- /storybook/components/empty.story.exs: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.Storybook.Components.Empty do 2 | use PhoenixStorybook.Story, :component 3 | 4 | alias AtomicWeb.Components.Empty 5 | 6 | def function, do: &Empty.empty_state/1 7 | 8 | def variations do 9 | [ 10 | %Variation{ 11 | id: :default, 12 | attributes: %{ 13 | placeholder: "item", 14 | url: "#" 15 | } 16 | } 17 | ] 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /storybook/components/gradient.story.exs: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.Storybook.Components.Gradient do 2 | use PhoenixStorybook.Story, :component 3 | 4 | alias AtomicWeb.Components.Gradient 5 | 6 | def function, do: &Gradient.gradient/1 7 | 8 | def template do 9 | """ 10 |
    11 | <.lsb-variation/> 12 |
    13 | """ 14 | end 15 | 16 | def variations do 17 | [ 18 | %Variation{ 19 | id: :random 20 | }, 21 | %Variation{ 22 | id: :predictable, 23 | attributes: %{ 24 | seed: "CAOS" 25 | } 26 | } 27 | ] 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /storybook/components/icon.story.exs: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.Storybook.Components.Icon do 2 | use PhoenixStorybook.Story, :component 3 | 4 | alias AtomicWeb.Components.Icon 5 | 6 | def function, do: &Icon.icon/1 7 | 8 | def template do 9 | """ 10 | 11 | <.lsb-variation /> 12 | 13 | """ 14 | end 15 | 16 | def variations do 17 | [ 18 | %Variation{ 19 | id: :hero_outline, 20 | description: "Heroicon outline", 21 | attributes: %{ 22 | name: "hero-academic-cap" 23 | } 24 | }, 25 | %Variation{ 26 | id: :hero_solid, 27 | description: "Heroicon solid", 28 | attributes: %{ 29 | name: "hero-academic-cap-solid" 30 | } 31 | }, 32 | %Variation{ 33 | id: :hero_mini, 34 | description: "Heroicon mini", 35 | attributes: %{ 36 | name: "hero-academic-cap-mini" 37 | } 38 | }, 39 | %Variation{ 40 | id: :hero_micro, 41 | description: "Heroicon micro", 42 | attributes: %{ 43 | name: "hero-academic-cap-micro" 44 | } 45 | }, 46 | %Variation{ 47 | id: :tabler_outline, 48 | description: "Tabler outline", 49 | attributes: %{ 50 | name: "tabler-affiliate" 51 | } 52 | }, 53 | %Variation{ 54 | id: :tabler_filled, 55 | description: "Tabler filled", 56 | attributes: %{ 57 | name: "tabler-affiliate-filled" 58 | } 59 | } 60 | ] 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /storybook/components/map.story.exs: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.Storybook.Components.Map do 2 | use PhoenixStorybook.Story, :component 3 | 4 | alias AtomicWeb.Components.Map 5 | 6 | def function, do: &Map.map/1 7 | 8 | def variations do 9 | [ 10 | %Variation{ 11 | id: :default, 12 | attributes: %{ 13 | location: "Centro de Estudantes de Engenharia Informática" 14 | } 15 | }, 16 | %VariationGroup{ 17 | id: :type, 18 | description: "Type", 19 | variations: [ 20 | %Variation{ 21 | id: :normal, 22 | attributes: %{ 23 | location: "Universidade do Minho - Campus de Gualtar", 24 | type: :normal 25 | } 26 | }, 27 | %Variation{ 28 | id: :satellite, 29 | attributes: %{ 30 | location: "Universidade do Minho - Campus de Gualtar", 31 | type: :satellite 32 | } 33 | } 34 | ] 35 | }, 36 | %Variation{ 37 | id: :zoom, 38 | attributes: %{ 39 | location: "Núcleo de Informática da AEFEUP", 40 | zoom: 7 41 | } 42 | }, 43 | %Variation{ 44 | id: :controls, 45 | attributes: %{ 46 | location: "Braga, Portugal", 47 | controls: true 48 | } 49 | } 50 | ] 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /storybook/components/spinner.story.exs: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.Storybook.Components.Spinner do 2 | use PhoenixStorybook.Story, :component 3 | 4 | alias AtomicWeb.Components.Spinner 5 | 6 | def function, do: &Spinner.spinner/1 7 | 8 | def variations do 9 | [ 10 | %Variation{ 11 | id: :default 12 | }, 13 | %VariationGroup{ 14 | id: :sizes, 15 | description: "Different sizes", 16 | variations: [ 17 | %Variation{ 18 | id: :extra_small, 19 | attributes: %{ 20 | size: :xs 21 | } 22 | }, 23 | %Variation{ 24 | id: :small, 25 | attributes: %{ 26 | size: :sm 27 | } 28 | }, 29 | %Variation{ 30 | id: :medium, 31 | attributes: %{ 32 | size: :md 33 | } 34 | }, 35 | %Variation{ 36 | id: :large, 37 | attributes: %{ 38 | size: :lg 39 | } 40 | }, 41 | %Variation{ 42 | id: :extra_large, 43 | attributes: %{ 44 | size: :xl 45 | } 46 | } 47 | ] 48 | }, 49 | %VariationGroup{ 50 | id: :colors, 51 | description: "Colors", 52 | variations: [ 53 | %Variation{ 54 | id: :red, 55 | attributes: %{ 56 | size: :md, 57 | class: "text-primary-500" 58 | } 59 | }, 60 | %Variation{ 61 | id: :small, 62 | attributes: %{ 63 | size: :lg, 64 | class: "text-secondary-500" 65 | } 66 | } 67 | ] 68 | } 69 | ] 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /storybook/components/tabs.story.exs: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.Storybook.Components.Tabs do 2 | use PhoenixStorybook.Story, :component 3 | 4 | alias AtomicWeb.Components.Icon 5 | alias AtomicWeb.Components.Tabs 6 | 7 | def function, do: &Tabs.tabs/1 8 | 9 | def imports, do: [{Tabs, tab: 1}, {Icon, icon: 1}] 10 | 11 | def variations do 12 | [ 13 | %Variation{ 14 | id: :simple, 15 | slots: [ 16 | """ 17 | <.tab active={true}> 18 | All 19 | 20 | <.tab> 21 | Following 22 | 23 | """ 24 | ] 25 | }, 26 | %Variation{ 27 | id: :with_numbers, 28 | slots: [ 29 | """ 30 | <.tab active={true} number={5}> 31 | All 32 | 33 | <.tab number={2}> 34 | Following 35 | 36 | """ 37 | ] 38 | }, 39 | %Variation{ 40 | id: :disabled, 41 | slots: [ 42 | """ 43 | <.tab active={true}> 44 | All 45 | 46 | <.tab disabled={true}> 47 | Following 48 | 49 | """ 50 | ] 51 | }, 52 | %Variation{ 53 | id: :custom_class, 54 | slots: [ 55 | """ 56 | <.tab active={true} class="bg-red-100 text-red-600"> 57 | All 58 | 59 | <.tab class="bg-blue-100 text-blue-600"> 60 | Following 61 | 62 | """ 63 | ] 64 | }, 65 | %Variation{ 66 | id: :with_icon, 67 | slots: [ 68 | """ 69 | <.tab active={true}> 70 | <.icon name="hero-home" class="size-5 mr-2" /> 71 | All 72 | 73 | <.tab> 74 | <.icon name="hero-star" class="size-5 mr-2" /> 75 | Following 76 | 77 | """ 78 | ] 79 | } 80 | ] 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /storybook/components/unauthenticated.story.exs: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.Storybook.Components.Unauthenticated do 2 | use PhoenixStorybook.Story, :component 3 | 4 | alias AtomicWeb.Components.Unauthenticated 5 | 6 | def function, do: &Unauthenticated.unauthenticated_state/1 7 | 8 | def variations do 9 | [ 10 | %Variation{ 11 | id: :default, 12 | attributes: %{} 13 | } 14 | ] 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/atomic_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.PageControllerTest do 2 | use AtomicWeb.ConnCase 3 | 4 | setup :register_and_log_in_user 5 | 6 | test "GET /", %{conn: conn} do 7 | conn = get(conn, "/") 8 | 9 | assert html_response(conn, 200) =~ "Home" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/atomic_web/controllers/user_registration_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.UserRegistrationControllerTest do 2 | use AtomicWeb.ConnCase, async: true 3 | 4 | describe "GET /users/register" do 5 | test "renders registration page", %{conn: conn} do 6 | conn = get(conn, ~p"/users/register") 7 | response = html_response(conn, 200) 8 | assert response =~ "Register for an account" 9 | assert response =~ "Log in" 10 | assert response =~ "Register" 11 | end 12 | 13 | test "redirects if already logged in", %{conn: conn} do 14 | conn = conn |> log_in_user(insert(:user)) |> get(~p"/users/register") 15 | assert redirected_to(conn) == "/" 16 | end 17 | end 18 | 19 | describe "POST /users/register" do 20 | @tag :capture_log 21 | test "creates account but doesn't log the user in", %{conn: conn} do 22 | user_attrs = %{ 23 | name: Faker.Person.name(), 24 | email: Faker.Internet.email(), 25 | role: "student", 26 | password: "password1234" 27 | } 28 | 29 | conn = 30 | post(conn, ~p"/users/register", %{ 31 | "user" => user_attrs 32 | }) 33 | 34 | assert is_nil(get_session(conn, :user_token)) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/atomic_web/helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.HelpersTest do 2 | @moduledoc """ 3 | Tests for the AtomicWeb.Helpers module 4 | """ 5 | use ExUnit.Case, async: true 6 | 7 | import AtomicWeb.Helpers 8 | 9 | doctest AtomicWeb.Helpers 10 | end 11 | -------------------------------------------------------------------------------- /test/atomic_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.ErrorViewTest do 2 | use AtomicWeb.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(AtomicWeb.ErrorView, "404.html.heex", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(AtomicWeb.ErrorView, "500.html.heex", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/atomic_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.LayoutViewTest do 2 | use AtomicWeb.ConnCase, async: true 3 | 4 | # When testing helpers, you may want to import Phoenix.HTML and 5 | # use functions such as safe_to_string() to convert the helper 6 | # result into an HTML string. 7 | # import Phoenix.HTML 8 | end 9 | -------------------------------------------------------------------------------- /test/atomic_web/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.PageViewTest do 2 | use AtomicWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use AtomicWeb.ChannelCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | alias Ecto.Adapters.SQL.Sandbox 20 | 21 | using do 22 | quote do 23 | # Import conveniences for testing with channels 24 | import Phoenix.ChannelTest 25 | import AtomicWeb.ChannelCase 26 | 27 | # The default endpoint for testing 28 | @endpoint AtomicWeb.Endpoint 29 | end 30 | end 31 | 32 | setup tags do 33 | pid = Sandbox.start_owner!(Atomic.Repo, shared: not tags[:async]) 34 | on_exit(fn -> Sandbox.stop_owner(pid) end) 35 | :ok 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | we enable the SQL sandbox, so changes done to the database 11 | are reverted at the end of every test. If you are using 12 | PostgreSQL, you can even run database tests asynchronously 13 | by setting `use Atomic.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | alias Ecto.Adapters.SQL 20 | 21 | using do 22 | quote do 23 | alias Atomic.Repo 24 | 25 | import Ecto 26 | import Ecto.Changeset 27 | import Ecto.Query 28 | import Atomic.DataCase 29 | end 30 | end 31 | 32 | setup tags do 33 | Atomic.DataCase.setup_sandbox(tags) 34 | :ok 35 | end 36 | 37 | @doc """ 38 | Sets up the sandbox based on the test tags. 39 | """ 40 | def setup_sandbox(tags) do 41 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Atomic.Repo, shared: not tags[:async]) 42 | on_exit(fn -> SQL.Sandbox.stop_owner(pid) end) 43 | end 44 | 45 | @doc """ 46 | A helper that transforms changeset errors into a map of messages. 47 | 48 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 49 | assert "password is too short" in errors_on(changeset).password 50 | assert %{password: ["password is too short"]} = errors_on(changeset) 51 | 52 | """ 53 | def errors_on(changeset) do 54 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 55 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 56 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 57 | end) 58 | end) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/support/factories/accounts_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Factories.AccountFactory do 2 | @moduledoc """ 3 | A factory to generate account related structs 4 | """ 5 | 6 | alias Atomic.Accounts.User 7 | 8 | defmacro __using__(_opts) do 9 | quote do 10 | @roles User.roles() 11 | 12 | def user_factory do 13 | %User{ 14 | name: Faker.Person.name(), 15 | email: Faker.Internet.email(), 16 | slug: Faker.Internet.user_name(), 17 | role: Enum.random(@roles), 18 | hashed_password: Bcrypt.hash_pwd_salt("password1234") 19 | } 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/support/factories/activities_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Factories.ActivityFactory do 2 | @moduledoc """ 3 | A factory to generate account related structs 4 | """ 5 | alias Atomic.Activities.{Activity, Enrollment} 6 | 7 | defmacro __using__(_opts) do 8 | quote do 9 | def activity_factory do 10 | organization = insert(:organization) 11 | 12 | %Activity{ 13 | title: Faker.Beer.brand(), 14 | description: Faker.Lorem.paragraph(), 15 | minimum_entries: Enum.random(1..10), 16 | maximum_entries: Enum.random(11..20), 17 | enrolled: 0, 18 | start: NaiveDateTime.utc_now(), 19 | finish: NaiveDateTime.utc_now() |> NaiveDateTime.add(1, :hour), 20 | organization_id: organization.id, 21 | post: build(:post, type: "activity") 22 | } 23 | end 24 | 25 | def enrollment_factory do 26 | %Enrollment{ 27 | present: Enum.random([true, false]), 28 | activity: build(:activity), 29 | user: build(:user) 30 | } 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/support/factories/departments_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Factories.DepartmentFactory do 2 | @moduledoc """ 3 | A factory to generate account related structs 4 | """ 5 | 6 | alias Atomic.Organizations.Department 7 | 8 | defmacro __using__(_opts) do 9 | quote do 10 | @departments [ 11 | "Pedagogical Department", 12 | "CAOS Department", 13 | "Department of Image", 14 | "Department of Partnerships", 15 | "Recreational Department" 16 | ] 17 | 18 | def department_factory do 19 | organization = insert(:organization) 20 | 21 | %Department{ 22 | name: Enum.random(@departments), 23 | organization_id: organization.id 24 | } 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/support/factories/feed_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Factories.FeedFactory do 2 | @moduledoc """ 3 | A factory to generate feed related structs 4 | """ 5 | alias Atomic.Feed.Post 6 | 7 | defmacro __using__(_opts) do 8 | quote do 9 | def post_factory do 10 | %Post{ 11 | type: Enum.random([:activity, :announcement]) 12 | } 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/support/factories/organizations_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Factories.OrganizationFactory do 2 | @moduledoc """ 3 | A factory to generate organization related structs. 4 | """ 5 | alias Atomic.Organizations.{ 6 | Announcement, 7 | Membership, 8 | Organization 9 | } 10 | 11 | defmacro __using__(_opts) do 12 | quote do 13 | @roles Membership.roles() 14 | 15 | def organization_factory do 16 | %Organization{ 17 | name: Faker.Company.name(), 18 | long_name: Faker.Company.name(), 19 | description: Faker.Lorem.paragraph() 20 | } 21 | end 22 | 23 | def membership_factory do 24 | %Membership{ 25 | user: build(:user), 26 | organization: build(:organization), 27 | role: Enum.random(@roles) 28 | } 29 | end 30 | 31 | def announcement_factory do 32 | organization = insert(:organization) 33 | 34 | %Announcement{ 35 | title: Faker.Company.buzzword(), 36 | description: Faker.Lorem.paragraph(), 37 | post: build(:post, type: "announcement"), 38 | organization_id: organization.id 39 | } 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/support/factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.Factory do 2 | @moduledoc false 3 | use ExMachina.Ecto, repo: Atomic.Repo 4 | 5 | use Atomic.Factories.{ 6 | AccountFactory, 7 | ActivityFactory, 8 | DepartmentFactory, 9 | FeedFactory, 10 | OrganizationFactory 11 | } 12 | end 13 | -------------------------------------------------------------------------------- /test/support/fixtures/accounts_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.AccountsFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `Atomic.Accounts` context. 5 | """ 6 | 7 | def unique_user_email, do: "user#{System.unique_integer()}@example.com" 8 | def valid_user_password, do: "password1234" 9 | 10 | def valid_user_attributes(attrs \\ %{}) do 11 | Enum.into(attrs, %{ 12 | email: unique_user_email(), 13 | password: valid_user_password() 14 | }) 15 | end 16 | 17 | def user_fixture(attrs \\ %{}) do 18 | {:ok, user} = 19 | attrs 20 | |> valid_user_attributes() 21 | |> Atomic.Accounts.register_user() 22 | 23 | user 24 | end 25 | 26 | def extract_user_token(fun) do 27 | {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]") 28 | 29 | body = 30 | if captured_email.html_body do 31 | captured_email.html_body 32 | else 33 | captured_email.text_body 34 | end 35 | 36 | [_, token | _] = String.split(body, "[TOKEN]") 37 | token 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/support/fixtures/activities_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.ActivitiesFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `Atomic.Activities` context. 5 | """ 6 | 7 | alias Atomic.OrganizationsFixtures 8 | 9 | @doc """ 10 | Generate a activity. 11 | """ 12 | def activity_fixture(attrs \\ %{}) do 13 | {:ok, activity} = 14 | attrs 15 | |> Enum.into(%{ 16 | description: "some description", 17 | title: "some title", 18 | maximum_entries: 42, 19 | minimum_entries: 0, 20 | finish: ~N[2022-10-22 20:00:00], 21 | start: ~N[2022-10-22 20:00:00], 22 | organization_id: OrganizationsFixtures.organization_fixture().id 23 | }) 24 | |> Atomic.Activities.create_activity_with_post() 25 | 26 | activity 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/support/fixtures/feed_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.FeedFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `Atomic.Feed` context. 5 | """ 6 | 7 | @doc """ 8 | Generate a post. 9 | """ 10 | def post_fixture(attrs \\ %{}) do 11 | {:ok, post} = 12 | attrs 13 | |> Enum.into(%{ 14 | type: "activity" 15 | }) 16 | |> Atomic.Feed.create_post() 17 | 18 | post 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/support/fixtures/organizations_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.OrganizationsFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `Atomic.Organizations` context. 5 | """ 6 | 7 | @doc """ 8 | Generate an organization. 9 | """ 10 | def organization_fixture(attrs \\ %{}) do 11 | {:ok, organization} = 12 | attrs 13 | |> Enum.into(%{ 14 | description: "some description", 15 | name: "SN", 16 | long_name: "some name" 17 | }) 18 | |> Atomic.Organizations.create_organization() 19 | 20 | organization 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/support/fixtures/partnerships_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule Atomic.PartnersFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `Atomic.Partners` context. 5 | """ 6 | 7 | alias Atomic.OrganizationsFixtures 8 | 9 | @doc """ 10 | Generate a partner. 11 | """ 12 | def partner_fixture(attrs \\ %{}) do 13 | {:ok, partner} = 14 | attrs 15 | |> Enum.into(%{ 16 | description: "some description", 17 | name: "some name", 18 | organization_id: OrganizationsFixtures.organization_fixture().id 19 | }) 20 | |> Atomic.Partners.create_partner() 21 | 22 | partner 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(Atomic.Repo, :manual) 3 | {:ok, _} = Application.ensure_all_started(:ex_machina) 4 | --------------------------------------------------------------------------------