Resources
9 |-
10 |
- 11 | Guides & Docs 12 | 13 |
- 14 | Source 15 | 16 |
- 17 | v1.6 Changelog 18 | 19 |
├── .formatter.exs ├── .gitignore ├── README.md ├── assets ├── .prettierrc ├── build.js ├── css │ ├── app.css │ └── phoenix.css ├── js │ ├── app.js │ ├── components │ │ ├── e-2-e │ │ │ ├── ChatWindow.svelte │ │ │ ├── ContactChat.svelte │ │ │ └── EmptyContactChat.svelte │ │ ├── no-event │ │ │ ├── ClientSide.svelte │ │ │ ├── ContactCard.svelte │ │ │ └── EmptyContactCard.svelte │ │ ├── patch-event │ │ │ ├── ContactCard.svelte │ │ │ ├── ContactList.svelte │ │ │ └── EmptyContactCard.svelte │ │ ├── push-event │ │ │ ├── Contact.svelte │ │ │ └── ContactForm.svelte │ │ └── simple-svelte-components │ │ │ ├── HelloSvelte.svelte │ │ │ └── Nested.svelte │ └── hooks.js ├── package.json ├── tailwind.config.js └── vendor │ └── topbar.js ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── runtime.exs └── test.exs ├── lib ├── swiphly.ex ├── swiphly │ ├── application.ex │ ├── mailer.ex │ ├── repo.ex │ ├── schema.ex │ ├── visitors.ex │ └── visitors │ │ ├── chat.ex │ │ └── contact.ex ├── swiphly_web.ex └── swiphly_web │ ├── components │ └── svelte_component.ex │ ├── controllers │ └── page_controller.ex │ ├── endpoint.ex │ ├── gettext.ex │ ├── live │ ├── end_to_end.ex │ ├── no_event.ex │ ├── patch_event.ex │ ├── push_event.ex │ └── simple_svelte_component.ex │ ├── router.ex │ ├── telemetry.ex │ ├── templates │ ├── layout │ │ ├── app.html.heex │ │ ├── live.html.heex │ │ └── root.html.heex │ └── page │ │ └── index.html.heex │ └── views │ ├── error_helpers.ex │ ├── error_view.ex │ ├── layout_view.ex │ └── page_view.ex ├── mix.exs ├── mix.lock ├── priv ├── gettext │ ├── en │ │ └── LC_MESSAGES │ │ │ └── errors.po │ └── errors.pot ├── repo │ ├── migrations │ │ ├── .formatter.exs │ │ ├── 20220830185553_create_contacts.exs │ │ └── 20220831021612_create_chats.exs │ └── seeds.exs └── static │ ├── favicon.ico │ ├── images │ ├── phoenix-logo.png │ ├── phoenix-logo.png:Zone.Identifier │ ├── phoenix.png │ ├── svelte-logo.png │ └── svelte-logo.png:Zone.Identifier │ └── robots.txt └── test ├── support ├── conn_case.ex ├── data_case.ex └── fixtures │ └── visitors_fixtures.ex ├── swiphly └── visitors_test.exs ├── swiphly_web ├── controllers │ └── page_controller_test.exs └── views │ ├── error_view_test.exs │ ├── layout_view_test.exs │ └── page_view_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :phoenix], 3 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | subdirectories: ["priv/*/migrations"] 5 | ] 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | swiphly-*.tar 24 | 25 | # Ignore assets that are produced by build tools. 26 | /priv/static/assets/ 27 | 28 | # Ignore digested assets cache. 29 | /priv/static/cache_manifest.json 30 | 31 | # In case you use Node.js/npm, you want to ignore these. 32 | npm-debug.log 33 | /assets/node_modules/ 34 | 35 | # Database files 36 | *.db 37 | *.db-* 38 | 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # elixirconf2022 2 | Code examples for ElixirConf 2022 talk on using Svelte with Phoenix LiveView. 3 | 4 | A more descriptive write-up of the talk can be found [here on dev.to](https://dev.to/debussyman/e2e-reactivity-using-svelte-with-phoenix-liveview-38mf). 5 | 6 | [](https://www.youtube.com/watch?v=asm2TTm035o) 7 | 8 | This project is built with the following components: 9 | - Phoenix v1.6.11 10 | - Svelte v3.49.0 11 | - npm modules tailwindcss, postcss, autoprefixier, esbuild-style-plugin, daisyui, esbuild-svelte 12 | 13 | # This project 14 | Someone once said there are three hard things in programming - 15 | 1. Naming 16 | 2. Off-by-one errors 17 | 18 | Fortunately, we're more clever than that. 19 | Let's call this Phoenix app **S**velte **wi**th **ph**oenix, serious**ly**. 20 | 21 | ## Initial setup 22 | 23 | We initialized this code repository with the following commands. If you clone this repo, you can skip these steps, but when starting from scratch, create a new Phoenix app in an empty directory (`.` after `phx.new` indicates current directory). 24 | ``` 25 | mix phx.new . --app swiphly --database sqlite3 26 | ``` 27 | > note: if you are starting from scratch and getting an error with sqlite3, you might need to update your phx\_new app: 28 | `mix archive.install hex phx_new` 29 | 30 | ### Configure esbuild 31 | 32 | **This is a key step when setting up from scrach** 33 | 34 | We will be using an esbuild plugin to build Svelte and Tailwindcss components. This requires several changes to the Phoenix app generated by `phx.new`. 35 | Modify the Phoenix app to support esbuild plugins, bypassing the default configuration of esbuild (via the Elixir wrapper). 36 | Follow instructions here - https://hexdocs.pm/phoenix/asset_management.html#esbuild-plugins 37 | 38 | ### Node modules 39 | This application needs following node dependences to be installed. They can be installed as dev dependencies since esbuild will be bundling all of the JavaScript and CSS into static assets served by Phoenix. 40 | 41 | It is also necessary to install the Phoenix npm packages, since we are bypassing the Elixir esbuild wrapper, as noted above. 42 | ``` 43 | npm install esbuild svelte tailwindcss postcss autoprefixer esbuild-svelte esbuild-style-plugin daisyui @faker-js/faker --save-dev 44 | npm install ../deps/phoenix ../deps/phoenix_html ../deps/phoenix_live_view --save 45 | ``` 46 | 47 | ### Ecto tables 48 | The following database tables are used by this demo 49 | ``` 50 | mix phx.gen.context Visitors Contact contacts name:string title:string company:string event:string 51 | mix phx.gen.context Visitors Chat chats contact_id:integer message:string 52 | ``` 53 | 54 | ## Setup 55 | On first clone, you'll need to setup Elixir and run migrations to create the database: 56 | ``` 57 | mix setup 58 | mix ecto.migrate 59 | ``` 60 | 61 | ## Running 62 | To run the Phoenix server with an interactive shell, use the following command: 63 | ``` 64 | iex -S mix phx.server 65 | ``` 66 | 67 | ## Key moving parts 68 | Let's highlight key files that play a main role in integrating Svelte into Phoenix LiveView - 69 | ``` 70 | ./ 71 | /assets 72 | build.js # added plugins to esbuild for svelte and postcss (for tailwindcss) 73 | /js 74 | app.js # include custom hook when instantiating livesocket 75 | hooks.js # define custom hook for linking LiveComponent lifecycle with Svelte component 76 | /components # directory containing all Svelte component files 77 | /lib 78 | /swiphly_web 79 | router.ex # maps webpage routes to LiveView pages 80 | /components 81 | svelte_component.ex # Simple reusable LiveView component for rendering HTML div with data attributes 82 | /live 83 | *.ex # LiveView pages corresponding to webpage routes 84 | ``` 85 | 86 | ### Resources 87 | https://hexdocs.pm/phoenix/asset_management.html#esbuild-plugins 88 | https://hexdocs.pm/phoenix_live_view/Phoenix.LiveComponent.html 89 | https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#attach_hook/4 90 | https://github.com/EMH333/esbuild-svelte 91 | https://svelte.dev/tutorial/ 92 | -------------------------------------------------------------------------------- /assets/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "none", 6 | "printWidth": 100 7 | } -------------------------------------------------------------------------------- /assets/build.js: -------------------------------------------------------------------------------- 1 | const esbuild = require('esbuild') 2 | const sveltePlugin = require('esbuild-svelte') 3 | const postCssPlugin = require('esbuild-style-plugin') 4 | 5 | const args = process.argv.slice(2) 6 | const watch = args.includes('--watch') 7 | const deploy = args.includes('--deploy') 8 | 9 | const loader = { 10 | // Add loaders for images/fonts/etc, e.g. { '.svg': 'file' } 11 | } 12 | 13 | const plugins = [ 14 | // Add and configure plugins here 15 | sveltePlugin(), 16 | postCssPlugin({ 17 | postcss: { 18 | plugins: [require('tailwindcss'), require('autoprefixer')], 19 | }, 20 | }) 21 | ] 22 | 23 | let opts = { 24 | entryPoints: ['js/app.js'], 25 | mainFields: ["svelte", "browser", "module", "main"], 26 | bundle: true, 27 | minify: false, 28 | target: 'es2017', 29 | outdir: '../priv/static/assets', 30 | logLevel: 'info', 31 | loader, 32 | plugins 33 | } 34 | 35 | if (watch) { 36 | opts = { 37 | ...opts, 38 | watch, 39 | sourcemap: 'inline' 40 | } 41 | } 42 | 43 | if (deploy) { 44 | opts = { 45 | ...opts, 46 | minify: true 47 | } 48 | } 49 | 50 | const promise = esbuild.build(opts) 51 | 52 | if (watch) { 53 | promise.then(_result => { 54 | process.stdin.on('close', () => { 55 | process.exit(0) 56 | }) 57 | 58 | process.stdin.resume() 59 | }) 60 | } -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | /* This file is for your main application CSS */ 2 | /* @import "./phoenix.css"; */ 3 | @import "tailwindcss/base"; 4 | @import "tailwindcss/components"; 5 | @import "tailwindcss/utilities"; 6 | 7 | /* Alerts and form errors used by phx.new */ 8 | .alert { 9 | padding: 15px; 10 | margin-bottom: 20px; 11 | border: 1px solid transparent; 12 | border-radius: 4px; 13 | } 14 | .alert-info { 15 | color: #31708f; 16 | background-color: #d9edf7; 17 | border-color: #bce8f1; 18 | } 19 | .alert-warning { 20 | color: #8a6d3b; 21 | background-color: #fcf8e3; 22 | border-color: #faebcc; 23 | } 24 | .alert-danger { 25 | color: #a94442; 26 | background-color: #f2dede; 27 | border-color: #ebccd1; 28 | } 29 | .alert p { 30 | margin-bottom: 0; 31 | } 32 | .alert:empty { 33 | display: none; 34 | } 35 | .invalid-feedback { 36 | color: #a94442; 37 | display: block; 38 | margin: -1rem 0 2rem; 39 | } 40 | 41 | /* LiveView specific classes for your customization */ 42 | .phx-no-feedback.invalid-feedback, 43 | .phx-no-feedback .invalid-feedback { 44 | display: none; 45 | } 46 | 47 | .phx-click-loading { 48 | opacity: 0.5; 49 | transition: opacity 1s ease-out; 50 | } 51 | 52 | .phx-loading{ 53 | cursor: wait; 54 | } 55 | 56 | .phx-modal { 57 | opacity: 1!important; 58 | position: fixed; 59 | z-index: 1; 60 | left: 0; 61 | top: 0; 62 | width: 100%; 63 | height: 100%; 64 | overflow: auto; 65 | background-color: rgba(0,0,0,0.4); 66 | } 67 | 68 | .phx-modal-content { 69 | background-color: #fefefe; 70 | margin: 15vh auto; 71 | padding: 20px; 72 | border: 1px solid #888; 73 | width: 80%; 74 | } 75 | 76 | .phx-modal-close { 77 | color: #aaa; 78 | float: right; 79 | font-size: 28px; 80 | font-weight: bold; 81 | } 82 | 83 | .phx-modal-close:hover, 84 | .phx-modal-close:focus { 85 | color: black; 86 | text-decoration: none; 87 | cursor: pointer; 88 | } 89 | 90 | .fade-in-scale { 91 | animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys; 92 | } 93 | 94 | .fade-out-scale { 95 | animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys; 96 | } 97 | 98 | .fade-in { 99 | animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys; 100 | } 101 | .fade-out { 102 | animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys; 103 | } 104 | 105 | @keyframes fade-in-scale-keys{ 106 | 0% { scale: 0.95; opacity: 0; } 107 | 100% { scale: 1.0; opacity: 1; } 108 | } 109 | 110 | @keyframes fade-out-scale-keys{ 111 | 0% { scale: 1.0; opacity: 1; } 112 | 100% { scale: 0.95; opacity: 0; } 113 | } 114 | 115 | @keyframes fade-in-keys{ 116 | 0% { opacity: 0; } 117 | 100% { opacity: 1; } 118 | } 119 | 120 | @keyframes fade-out-keys{ 121 | 0% { opacity: 1; } 122 | 100% { opacity: 0; } 123 | } 124 | -------------------------------------------------------------------------------- /assets/css/phoenix.css: -------------------------------------------------------------------------------- 1 | /* Includes some default style for the starter application. 2 | * This can be safely deleted to start fresh. 3 | */ 4 | 5 | /* Milligram v1.4.1 https://milligram.github.io 6 | * Copyright (c) 2020 CJ Patoilo Licensed under the MIT license 7 | */ 8 | 9 | *,*:after,*:before{box-sizing:inherit}html{box-sizing:border-box;font-size:62.5%}body{color:#000000;font-family:'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;font-size:1.6em;font-weight:300;letter-spacing:.01em;line-height:1.6}blockquote{border-left:0.3rem solid #d1d1d1;margin-left:0;margin-right:0;padding:1rem 1.5rem}blockquote *:last-child{margin-bottom:0}.button,button,input[type='button'],input[type='reset'],input[type='submit']{background-color:#0069d9;border:0.1rem solid #0069d9;border-radius:.4rem;color:#fff;cursor:pointer;display:inline-block;font-size:1.1rem;font-weight:700;height:3.8rem;letter-spacing:.1rem;line-height:3.8rem;padding:0 3.0rem;text-align:center;text-decoration:none;text-transform:uppercase;white-space:nowrap}.button:focus,.button:hover,button:focus,button:hover,input[type='button']:focus,input[type='button']:hover,input[type='reset']:focus,input[type='reset']:hover,input[type='submit']:focus,input[type='submit']:hover{background-color:#606c76;border-color:#606c76;color:#fff;outline:0}.button[disabled],button[disabled],input[type='button'][disabled],input[type='reset'][disabled],input[type='submit'][disabled]{cursor:default;opacity:.5}.button[disabled]:focus,.button[disabled]:hover,button[disabled]:focus,button[disabled]:hover,input[type='button'][disabled]:focus,input[type='button'][disabled]:hover,input[type='reset'][disabled]:focus,input[type='reset'][disabled]:hover,input[type='submit'][disabled]:focus,input[type='submit'][disabled]:hover{background-color:#0069d9;border-color:#0069d9}.button.button-outline,button.button-outline,input[type='button'].button-outline,input[type='reset'].button-outline,input[type='submit'].button-outline{background-color:transparent;color:#0069d9}.button.button-outline:focus,.button.button-outline:hover,button.button-outline:focus,button.button-outline:hover,input[type='button'].button-outline:focus,input[type='button'].button-outline:hover,input[type='reset'].button-outline:focus,input[type='reset'].button-outline:hover,input[type='submit'].button-outline:focus,input[type='submit'].button-outline:hover{background-color:transparent;border-color:#606c76;color:#606c76}.button.button-outline[disabled]:focus,.button.button-outline[disabled]:hover,button.button-outline[disabled]:focus,button.button-outline[disabled]:hover,input[type='button'].button-outline[disabled]:focus,input[type='button'].button-outline[disabled]:hover,input[type='reset'].button-outline[disabled]:focus,input[type='reset'].button-outline[disabled]:hover,input[type='submit'].button-outline[disabled]:focus,input[type='submit'].button-outline[disabled]:hover{border-color:inherit;color:#0069d9}.button.button-clear,button.button-clear,input[type='button'].button-clear,input[type='reset'].button-clear,input[type='submit'].button-clear{background-color:transparent;border-color:transparent;color:#0069d9}.button.button-clear:focus,.button.button-clear:hover,button.button-clear:focus,button.button-clear:hover,input[type='button'].button-clear:focus,input[type='button'].button-clear:hover,input[type='reset'].button-clear:focus,input[type='reset'].button-clear:hover,input[type='submit'].button-clear:focus,input[type='submit'].button-clear:hover{background-color:transparent;border-color:transparent;color:#606c76}.button.button-clear[disabled]:focus,.button.button-clear[disabled]:hover,button.button-clear[disabled]:focus,button.button-clear[disabled]:hover,input[type='button'].button-clear[disabled]:focus,input[type='button'].button-clear[disabled]:hover,input[type='reset'].button-clear[disabled]:focus,input[type='reset'].button-clear[disabled]:hover,input[type='submit'].button-clear[disabled]:focus,input[type='submit'].button-clear[disabled]:hover{color:#0069d9}code{background:#f4f5f6;border-radius:.4rem;font-size:86%;margin:0 .2rem;padding:.2rem .5rem;white-space:nowrap}pre{background:#f4f5f6;border-left:0.3rem solid #0069d9;overflow-y:hidden}pre>code{border-radius:0;display:block;padding:1rem 1.5rem;white-space:pre}hr{border:0;border-top:0.1rem solid #f4f5f6;margin:3.0rem 0}input[type='color'],input[type='date'],input[type='datetime'],input[type='datetime-local'],input[type='email'],input[type='month'],input[type='number'],input[type='password'],input[type='search'],input[type='tel'],input[type='text'],input[type='url'],input[type='week'],input:not([type]),textarea,select{-webkit-appearance:none;background-color:transparent;border:0.1rem solid #d1d1d1;border-radius:.4rem;box-shadow:none;box-sizing:inherit;height:3.8rem;padding:.6rem 1.0rem .7rem;width:100%}input[type='color']:focus,input[type='date']:focus,input[type='datetime']:focus,input[type='datetime-local']:focus,input[type='email']:focus,input[type='month']:focus,input[type='number']:focus,input[type='password']:focus,input[type='search']:focus,input[type='tel']:focus,input[type='text']:focus,input[type='url']:focus,input[type='week']:focus,input:not([type]):focus,textarea:focus,select:focus{border-color:#0069d9;outline:0}select{background:url('data:image/svg+xml;utf8,') center right no-repeat;padding-right:3.0rem}select:focus{background-image:url('data:image/svg+xml;utf8,')}select[multiple]{background:none;height:auto}textarea{min-height:6.5rem}label,legend{display:block;font-size:1.6rem;font-weight:700;margin-bottom:.5rem}fieldset{border-width:0;padding:0}input[type='checkbox'],input[type='radio']{display:inline}.label-inline{display:inline-block;font-weight:normal;margin-left:.5rem}.container{margin:0 auto;max-width:112.0rem;padding:0 2.0rem;position:relative;width:100%}.row{display:flex;flex-direction:column;padding:0;width:100%}.row.row-no-padding{padding:0}.row.row-no-padding>.column{padding:0}.row.row-wrap{flex-wrap:wrap}.row.row-top{align-items:flex-start}.row.row-bottom{align-items:flex-end}.row.row-center{align-items:center}.row.row-stretch{align-items:stretch}.row.row-baseline{align-items:baseline}.row .column{display:block;flex:1 1 auto;margin-left:0;max-width:100%;width:100%}.row .column.column-offset-10{margin-left:10%}.row .column.column-offset-20{margin-left:20%}.row .column.column-offset-25{margin-left:25%}.row .column.column-offset-33,.row .column.column-offset-34{margin-left:33.3333%}.row .column.column-offset-40{margin-left:40%}.row .column.column-offset-50{margin-left:50%}.row .column.column-offset-60{margin-left:60%}.row .column.column-offset-66,.row .column.column-offset-67{margin-left:66.6666%}.row .column.column-offset-75{margin-left:75%}.row .column.column-offset-80{margin-left:80%}.row .column.column-offset-90{margin-left:90%}.row .column.column-10{flex:0 0 10%;max-width:10%}.row .column.column-20{flex:0 0 20%;max-width:20%}.row .column.column-25{flex:0 0 25%;max-width:25%}.row .column.column-33,.row .column.column-34{flex:0 0 33.3333%;max-width:33.3333%}.row .column.column-40{flex:0 0 40%;max-width:40%}.row .column.column-50{flex:0 0 50%;max-width:50%}.row .column.column-60{flex:0 0 60%;max-width:60%}.row .column.column-66,.row .column.column-67{flex:0 0 66.6666%;max-width:66.6666%}.row .column.column-75{flex:0 0 75%;max-width:75%}.row .column.column-80{flex:0 0 80%;max-width:80%}.row .column.column-90{flex:0 0 90%;max-width:90%}.row .column .column-top{align-self:flex-start}.row .column .column-bottom{align-self:flex-end}.row .column .column-center{align-self:center}@media (min-width: 40rem){.row{flex-direction:row;margin-left:-1.0rem;width:calc(100% + 2.0rem)}.row .column{margin-bottom:inherit;padding:0 1.0rem}}a{color:#0069d9;text-decoration:none}a:focus,a:hover{color:#606c76}dl,ol,ul{list-style:none;margin-top:0;padding-left:0}dl dl,dl ol,dl ul,ol dl,ol ol,ol ul,ul dl,ul ol,ul ul{font-size:90%;margin:1.5rem 0 1.5rem 3.0rem}ol{list-style:decimal inside}ul{list-style:circle inside}.button,button,dd,dt,li{margin-bottom:1.0rem}fieldset,input,select,textarea{margin-bottom:1.5rem}blockquote,dl,figure,form,ol,p,pre,table,ul{margin-bottom:2.5rem}table{border-spacing:0;display:block;overflow-x:auto;text-align:left;width:100%}td,th{border-bottom:0.1rem solid #e1e1e1;padding:1.2rem 1.5rem}td:first-child,th:first-child{padding-left:0}td:last-child,th:last-child{padding-right:0}@media (min-width: 40rem){table{display:table;overflow-x:initial}}b,strong{font-weight:bold}p{margin-top:0}h1,h2,h3,h4,h5,h6{font-weight:300;letter-spacing:-.1rem;margin-bottom:2.0rem;margin-top:0}h1{font-size:4.6rem;line-height:1.2}h2{font-size:3.6rem;line-height:1.25}h3{font-size:2.8rem;line-height:1.3}h4{font-size:2.2rem;letter-spacing:-.08rem;line-height:1.35}h5{font-size:1.8rem;letter-spacing:-.05rem;line-height:1.5}h6{font-size:1.6rem;letter-spacing:0;line-height:1.4}img{max-width:100%}.clearfix:after{clear:both;content:' ';display:table}.float-left{float:left}.float-right{float:right} 10 | 11 | /* General style */ 12 | h1{font-size: 3.6rem; line-height: 1.25} 13 | h2{font-size: 2.8rem; line-height: 1.3} 14 | h3{font-size: 2.2rem; letter-spacing: -.08rem; line-height: 1.35} 15 | h4{font-size: 1.8rem; letter-spacing: -.05rem; line-height: 1.5} 16 | h5{font-size: 1.6rem; letter-spacing: 0; line-height: 1.4} 17 | h6{font-size: 1.4rem; letter-spacing: 0; line-height: 1.2} 18 | pre{padding: 1em;} 19 | 20 | .container{ 21 | margin: 0 auto; 22 | max-width: 80.0rem; 23 | padding: 0 2.0rem; 24 | position: relative; 25 | width: 100% 26 | } 27 | select { 28 | width: auto; 29 | } 30 | 31 | /* Phoenix promo and logo */ 32 | .phx-hero { 33 | text-align: center; 34 | border-bottom: 1px solid #e3e3e3; 35 | background: #eee; 36 | border-radius: 6px; 37 | padding: 3em 3em 1em; 38 | margin-bottom: 3rem; 39 | font-weight: 200; 40 | font-size: 120%; 41 | } 42 | .phx-hero input { 43 | background: #ffffff; 44 | } 45 | .phx-logo { 46 | min-width: 300px; 47 | margin: 1rem; 48 | display: block; 49 | } 50 | .phx-logo img { 51 | width: auto; 52 | display: block; 53 | } 54 | 55 | /* Headers */ 56 | header { 57 | width: 100%; 58 | background: #fdfdfd; 59 | border-bottom: 1px solid #eaeaea; 60 | margin-bottom: 2rem; 61 | } 62 | header section { 63 | align-items: center; 64 | display: flex; 65 | flex-direction: column; 66 | justify-content: space-between; 67 | } 68 | header section :first-child { 69 | order: 2; 70 | } 71 | header section :last-child { 72 | order: 1; 73 | } 74 | header nav ul, 75 | header nav li { 76 | margin: 0; 77 | padding: 0; 78 | display: block; 79 | text-align: right; 80 | white-space: nowrap; 81 | } 82 | header nav ul { 83 | margin: 1rem; 84 | margin-top: 0; 85 | } 86 | header nav a { 87 | display: block; 88 | } 89 | 90 | @media (min-width: 40.0rem) { /* Small devices (landscape phones, 576px and up) */ 91 | header section { 92 | flex-direction: row; 93 | } 94 | header nav ul { 95 | margin: 1rem; 96 | } 97 | .phx-logo { 98 | flex-basis: 527px; 99 | margin: 2rem 1rem; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // We import the CSS which is extracted to its own file by esbuild. 2 | // Remove this line if you add a your own CSS build pipeline (e.g postcss). 3 | import "../css/app.css" 4 | 5 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 6 | // to get started and then uncomment the line below. 7 | // import "./user_socket.js" 8 | 9 | // You can include dependencies in two ways. 10 | // 11 | // The simplest option is to put them in assets/vendor and 12 | // import them using relative paths: 13 | // 14 | // import "../vendor/some-package.js" 15 | // 16 | // Alternatively, you can `npm install some-package --prefix assets` and import 17 | // them using a path starting with the package name: 18 | // 19 | // import "some-package" 20 | // 21 | 22 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 23 | import "phoenix_html" 24 | // Establish Phoenix Socket and LiveView configuration. 25 | import {Socket} from "phoenix" 26 | import {LiveSocket} from "phoenix_live_view" 27 | import topbar from "../vendor/topbar" 28 | import Hooks from "./hooks" 29 | 30 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 31 | let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}, hooks: Hooks}) 32 | 33 | // Show progress bar on live navigation and form submits 34 | topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) 35 | window.addEventListener("phx:page-loading-start", info => topbar.show()) 36 | window.addEventListener("phx:page-loading-stop", info => topbar.hide()) 37 | 38 | // connect if there are any LiveViews on the page 39 | liveSocket.connect() 40 | 41 | // expose liveSocket on window for web console debug logs and latency simulation: 42 | // >> liveSocket.enableDebug() 43 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 44 | // >> liveSocket.disableLatencySim() 45 | window.liveSocket = liveSocket 46 | 47 | -------------------------------------------------------------------------------- /assets/js/components/e-2-e/ChatWindow.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 |
{chat.message}
16 |{contact.name} is the {contact.title} at {contact.company}
19 |{contact.name} is the {contact.title} at {contact.company}
19 |80 | | Name | 81 |Title | 82 |Company | 83 |Event | 84 |85 | |
---|---|---|---|---|---|
93 | | No contacts! | 94 |95 | | 96 | | 97 | |
This is {content}, in a silly font.
8 |This is other content, but as a nested Svelte component.
2 | -------------------------------------------------------------------------------- /assets/js/hooks.js: -------------------------------------------------------------------------------- 1 | import HelloSvelte from "./components/simple-svelte-components/HelloSvelte.svelte" 2 | import ContactForm from "./components/push-event/ContactForm.svelte" 3 | import ContactList from "./components/patch-event/ContactList.svelte" 4 | import NoEvent from "./components/no-event/ClientSide.svelte" 5 | import E2E from "./components/e-2-e/ChatWindow.svelte" 6 | 7 | const components = { 8 | HelloSvelte, 9 | ContactForm, 10 | ContactList, 11 | NoEvent, 12 | E2E 13 | } 14 | 15 | function parsedProps(el) { 16 | const props = el.getAttribute('data-props') 17 | return props ? JSON.parse(props) : {} 18 | } 19 | 20 | const SvelteComponent = { 21 | mounted() { 22 | const componentName = this.el.getAttribute('data-name') 23 | if (!componentName) { 24 | throw new Error('Component name must be provided') 25 | } 26 | 27 | const requiredApp = components[componentName] 28 | if (!requiredApp) { 29 | throw new Error(`Unable to find ${componentName} component. Did you forget to import it into hooks.js?`) 30 | } 31 | 32 | const request = (event, data, callback) => { 33 | this.pushEvent(event, data, callback) 34 | } 35 | 36 | const goto = (href) => { 37 | liveSocket.pushHistoryPatch(href, "push", this.el) 38 | } 39 | 40 | this._instance = new requiredApp({ 41 | target: this.el, 42 | props: {...parsedProps(this.el), request, goto }, 43 | }) 44 | }, 45 | 46 | updated() { 47 | const request = (event, data, callback) => { 48 | this.pushEvent(event, data, callback) 49 | } 50 | 51 | const goto = (href) => { 52 | liveSocket.pushHistoryPatch(href, "push", this.el) 53 | } 54 | 55 | this._instance.$$set({...parsedProps(this.el), request, goto }) 56 | }, 57 | 58 | destroyed() { 59 | this._instance?.$destroy() 60 | } 61 | } 62 | 63 | export default { 64 | SvelteComponent, 65 | } -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@faker-js/faker": "^7.5.0", 4 | "autoprefixer": "^10.4.8", 5 | "daisyui": "^2.24.0", 6 | "esbuild": "^0.15.6", 7 | "esbuild-style-plugin": "^1.6.0", 8 | "esbuild-svelte": "^0.7.1", 9 | "postcss": "^8.4.16", 10 | "svelte": "^3.49.0", 11 | "tailwindcss": "^3.1.8" 12 | }, 13 | "dependencies": { 14 | "phoenix": "file:../deps/phoenix", 15 | "phoenix_html": "file:../deps/phoenix_html", 16 | "phoenix_live_view": "file:../deps/phoenix_live_view" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const themeColors = { 2 | // shades generated from https://tailwind.ink/ 3 | steel: { 4 | DEFAULT: '#272d3a', 5 | '50': '#f8f9f9', 6 | '100': '#ebf1f5', 7 | '200': '#d3dfea', 8 | '300': '#a9bdd0', 9 | '400': '#7896ad', 10 | '500': '#5c738d', 11 | '600': '#4a596f', 12 | '700': '#414a57', 13 | '800': '#272d3a', 14 | '900': '#181b25', 15 | }, 16 | seagreen: { 17 | DEFAULT: '#81B29A', 18 | '50': '#f4f7f5', 19 | '100': '#e2efee', 20 | '200': '#bee3d9', 21 | '300': '#81b29a', 22 | '400': '#49a386', 23 | '500': '#34865f', 24 | '600': '#2c6d47', 25 | '700': '#265338', 26 | '800': '#1b392a', 27 | '900': '#12231e', 28 | }, 29 | chestnut: { 30 | DEFAULT: '#AD5D4E', 31 | '50': '#fcfbf9', 32 | '100': '#faf0e5', 33 | '200': '#f5d5c8', 34 | '300': '#e8ab9a', 35 | '400': '#de7c6c', 36 | '500': '#ad5d4e', 37 | '600': '#af3e31', 38 | '700': '#872e25', 39 | '800': '#5e201a', 40 | '900': '#3a1410', 41 | }, 42 | orchid: { 43 | DEFAULT: '#C490D1', 44 | '50': '#fafafb', 45 | '100': '#f4eff8', 46 | '200': '#e8d3f1', 47 | '300': '#d0ace0', 48 | '400': '#c490d1', 49 | '500': '#a95cb7', 50 | '600': '#8e409b', 51 | '700': '#6b3077', 52 | '800': '#4a2250', 53 | '900': '#2b152e', 54 | }, 55 | } 56 | 57 | module.exports = { 58 | content: [ 59 | './js/**/*.svelte', 60 | "../lib/**/*.heex", 61 | ], 62 | darkMode: 'media', 63 | theme: { 64 | extend: { 65 | colors: { 66 | ...themeColors, 67 | } 68 | }, 69 | }, 70 | variants: { 71 | extend: { 72 | opacity: ['disabled'], 73 | }, 74 | }, 75 | plugins: [require("daisyui")], 76 | mode: 'jit', 77 | } 78 | -------------------------------------------------------------------------------- /assets/vendor/topbar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license MIT 3 | * topbar 1.0.0, 2021-01-06 4 | * https://buunguyen.github.io/topbar 5 | * Copyright (c) 2021 Buu Nguyen 6 | */ 7 | (function (window, document) { 8 | "use strict"; 9 | 10 | // https://gist.github.com/paulirish/1579671 11 | (function () { 12 | var lastTime = 0; 13 | var vendors = ["ms", "moz", "webkit", "o"]; 14 | for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { 15 | window.requestAnimationFrame = 16 | window[vendors[x] + "RequestAnimationFrame"]; 17 | window.cancelAnimationFrame = 18 | window[vendors[x] + "CancelAnimationFrame"] || 19 | window[vendors[x] + "CancelRequestAnimationFrame"]; 20 | } 21 | if (!window.requestAnimationFrame) 22 | window.requestAnimationFrame = function (callback, element) { 23 | var currTime = new Date().getTime(); 24 | var timeToCall = Math.max(0, 16 - (currTime - lastTime)); 25 | var id = window.setTimeout(function () { 26 | callback(currTime + timeToCall); 27 | }, timeToCall); 28 | lastTime = currTime + timeToCall; 29 | return id; 30 | }; 31 | if (!window.cancelAnimationFrame) 32 | window.cancelAnimationFrame = function (id) { 33 | clearTimeout(id); 34 | }; 35 | })(); 36 | 37 | var canvas, 38 | progressTimerId, 39 | fadeTimerId, 40 | currentProgress, 41 | showing, 42 | addEvent = function (elem, type, handler) { 43 | if (elem.addEventListener) elem.addEventListener(type, handler, false); 44 | else if (elem.attachEvent) elem.attachEvent("on" + type, handler); 45 | else elem["on" + type] = handler; 46 | }, 47 | options = { 48 | autoRun: true, 49 | barThickness: 3, 50 | barColors: { 51 | 0: "rgba(26, 188, 156, .9)", 52 | ".25": "rgba(52, 152, 219, .9)", 53 | ".50": "rgba(241, 196, 15, .9)", 54 | ".75": "rgba(230, 126, 34, .9)", 55 | "1.0": "rgba(211, 84, 0, .9)", 56 | }, 57 | shadowBlur: 10, 58 | shadowColor: "rgba(0, 0, 0, .6)", 59 | className: null, 60 | }, 61 | repaint = function () { 62 | canvas.width = window.innerWidth; 63 | canvas.height = options.barThickness * 5; // need space for shadow 64 | 65 | var ctx = canvas.getContext("2d"); 66 | ctx.shadowBlur = options.shadowBlur; 67 | ctx.shadowColor = options.shadowColor; 68 | 69 | var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); 70 | for (var stop in options.barColors) 71 | lineGradient.addColorStop(stop, options.barColors[stop]); 72 | ctx.lineWidth = options.barThickness; 73 | ctx.beginPath(); 74 | ctx.moveTo(0, options.barThickness / 2); 75 | ctx.lineTo( 76 | Math.ceil(currentProgress * canvas.width), 77 | options.barThickness / 2 78 | ); 79 | ctx.strokeStyle = lineGradient; 80 | ctx.stroke(); 81 | }, 82 | createCanvas = function () { 83 | canvas = document.createElement("canvas"); 84 | var style = canvas.style; 85 | style.position = "fixed"; 86 | style.top = style.left = style.right = style.margin = style.padding = 0; 87 | style.zIndex = 100001; 88 | style.display = "none"; 89 | if (options.className) canvas.classList.add(options.className); 90 | document.body.appendChild(canvas); 91 | addEvent(window, "resize", repaint); 92 | }, 93 | topbar = { 94 | config: function (opts) { 95 | for (var key in opts) 96 | if (options.hasOwnProperty(key)) options[key] = opts[key]; 97 | }, 98 | show: function () { 99 | if (showing) return; 100 | showing = true; 101 | if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); 102 | if (!canvas) createCanvas(); 103 | canvas.style.opacity = 1; 104 | canvas.style.display = "block"; 105 | topbar.progress(0); 106 | if (options.autoRun) { 107 | (function loop() { 108 | progressTimerId = window.requestAnimationFrame(loop); 109 | topbar.progress( 110 | "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) 111 | ); 112 | })(); 113 | } 114 | }, 115 | progress: function (to) { 116 | if (typeof to === "undefined") return currentProgress; 117 | if (typeof to === "string") { 118 | to = 119 | (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 120 | ? currentProgress 121 | : 0) + parseFloat(to); 122 | } 123 | currentProgress = to > 1 ? 1 : to; 124 | repaint(); 125 | return currentProgress; 126 | }, 127 | hide: function () { 128 | if (!showing) return; 129 | showing = false; 130 | if (progressTimerId != null) { 131 | window.cancelAnimationFrame(progressTimerId); 132 | progressTimerId = null; 133 | } 134 | (function loop() { 135 | if (topbar.progress("+.1") >= 1) { 136 | canvas.style.opacity -= 0.05; 137 | if (canvas.style.opacity <= 0.05) { 138 | canvas.style.display = "none"; 139 | fadeTimerId = null; 140 | return; 141 | } 142 | } 143 | fadeTimerId = window.requestAnimationFrame(loop); 144 | })(); 145 | }, 146 | }; 147 | 148 | if (typeof module === "object" && typeof module.exports === "object") { 149 | module.exports = topbar; 150 | } else if (typeof define === "function" && define.amd) { 151 | define(function () { 152 | return topbar; 153 | }); 154 | } else { 155 | this.topbar = topbar; 156 | } 157 | }.call(this, window, document)); 158 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | config :swiphly, 11 | ecto_repos: [Swiphly.Repo] 12 | 13 | # Configures the endpoint 14 | config :swiphly, SwiphlyWeb.Endpoint, 15 | url: [host: "localhost"], 16 | render_errors: [view: SwiphlyWeb.ErrorView, accepts: ~w(html json), layout: false], 17 | pubsub_server: Swiphly.PubSub, 18 | live_view: [signing_salt: "8F/E4EjE"] 19 | 20 | # Configures the mailer 21 | # 22 | # By default it uses the "Local" adapter which stores the emails 23 | # locally. You can see the emails in your browser, at "/dev/mailbox". 24 | # 25 | # For production it's recommended to configure a different adapter 26 | # at the `config/runtime.exs`. 27 | config :swiphly, Swiphly.Mailer, adapter: Swoosh.Adapters.Local 28 | 29 | # Swoosh API client is needed for adapters other than SMTP. 30 | config :swoosh, :api_client, false 31 | 32 | # Configures Elixir's Logger 33 | config :logger, :console, 34 | format: "$time $metadata[$level] $message\n", 35 | metadata: [:request_id] 36 | 37 | # Use Jason for JSON parsing in Phoenix 38 | config :phoenix, :json_library, Jason 39 | 40 | # Import environment specific config. This must remain at the bottom 41 | # of this file so it overrides the configuration defined above. 42 | import_config "#{config_env()}.exs" 43 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | config :swiphly, Swiphly.Repo, 5 | database: Path.expand("../swiphly_dev.db", Path.dirname(__ENV__.file)), 6 | pool_size: 5, 7 | stacktrace: true, 8 | show_sensitive_data_on_connection_error: true 9 | 10 | # For development, we disable any cache and enable 11 | # debugging and code reloading. 12 | # 13 | # The watchers configuration can be used to run external 14 | # watchers to your application. For example, we use it 15 | # with esbuild to bundle .js and .css sources. 16 | config :swiphly, SwiphlyWeb.Endpoint, 17 | # Binding to loopback ipv4 address prevents access from other machines. 18 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 19 | http: [ip: {127, 0, 0, 1}, port: 4000], 20 | check_origin: false, 21 | code_reloader: true, 22 | debug_errors: true, 23 | secret_key_base: "QUWviuyGL/Sq8sPltD2d9GyGmxA30VMcOXWKSCWKTTERRZgwNcQlv5/bozIBbfo+", 24 | watchers: [ 25 | # Run asset build script whenever you change files 26 | # This replaces the default esbuilt that ships with Phoenix, to support additional plugins 27 | # More information in the Phoenix docs - https://hexdocs.pm/phoenix/asset_management.html#esbuild-plugins 28 | node: ["build.js", "--watch", cd: Path.expand("../assets", __DIR__)] 29 | ] 30 | 31 | # ## SSL Support 32 | # 33 | # In order to use HTTPS in development, a self-signed 34 | # certificate can be generated by running the following 35 | # Mix task: 36 | # 37 | # mix phx.gen.cert 38 | # 39 | # Note that this task requires Erlang/OTP 20 or later. 40 | # Run `mix help phx.gen.cert` for more information. 41 | # 42 | # The `http:` config above can be replaced with: 43 | # 44 | # https: [ 45 | # port: 4001, 46 | # cipher_suite: :strong, 47 | # keyfile: "priv/cert/selfsigned_key.pem", 48 | # certfile: "priv/cert/selfsigned.pem" 49 | # ], 50 | # 51 | # If desired, both `http:` and `https:` keys can be 52 | # configured to run both http and https servers on 53 | # different ports. 54 | 55 | # Watch static and templates for browser reloading. 56 | config :swiphly, SwiphlyWeb.Endpoint, 57 | live_reload: [ 58 | patterns: [ 59 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 60 | ~r"priv/gettext/.*(po)$", 61 | ~r"lib/swiphly_web/(live|views)/.*(ex)$", 62 | ~r"lib/swiphly_web/templates/.*(eex)$" 63 | ] 64 | ] 65 | 66 | # Do not include metadata nor timestamps in development logs 67 | config :logger, :console, format: "[$level] $message\n" 68 | 69 | # Set a higher stacktrace during development. Avoid configuring such 70 | # in production as building large stacktraces may be expensive. 71 | config :phoenix, :stacktrace_depth, 20 72 | 73 | # Initialize plugs at runtime for faster development compilation 74 | config :phoenix, :plug_init_mode, :runtime 75 | -------------------------------------------------------------------------------- /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 :swiphly, SwiphlyWeb.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 :swiphly, SwiphlyWeb.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 :swiphly, SwiphlyWeb.Endpoint, 47 | # force_ssl: [hsts: true] 48 | # 49 | # Check `Plug.SSL` for all available options in `force_ssl`. 50 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | 10 | # ## Using releases 11 | # 12 | # If you use `mix release`, you need to explicitly enable the server 13 | # by passing the PHX_SERVER=true when you start it: 14 | # 15 | # PHX_SERVER=true bin/swiphly start 16 | # 17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` 18 | # script that automatically sets the env var above. 19 | if System.get_env("PHX_SERVER") do 20 | config :swiphly, SwiphlyWeb.Endpoint, server: true 21 | end 22 | 23 | if config_env() == :prod do 24 | database_path = 25 | System.get_env("DATABASE_PATH") || 26 | raise """ 27 | environment variable DATABASE_PATH is missing. 28 | For example: /etc/swiphly/swiphly.db 29 | """ 30 | 31 | config :swiphly, Swiphly.Repo, 32 | database: database_path, 33 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5") 34 | 35 | # The secret key base is used to sign/encrypt cookies and other secrets. 36 | # A default value is used in config/dev.exs and config/test.exs but you 37 | # want to use a different value for prod and you most likely don't want 38 | # to check this value into version control, so we use an environment 39 | # variable instead. 40 | secret_key_base = 41 | System.get_env("SECRET_KEY_BASE") || 42 | raise """ 43 | environment variable SECRET_KEY_BASE is missing. 44 | You can generate one by calling: mix phx.gen.secret 45 | """ 46 | 47 | host = System.get_env("PHX_HOST") || "example.com" 48 | port = String.to_integer(System.get_env("PORT") || "4000") 49 | 50 | config :swiphly, SwiphlyWeb.Endpoint, 51 | url: [host: host, port: 443, scheme: "https"], 52 | http: [ 53 | # Enable IPv6 and bind on all interfaces. 54 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 55 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html 56 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 57 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 58 | port: port 59 | ], 60 | secret_key_base: secret_key_base 61 | 62 | # ## Configuring the mailer 63 | # 64 | # In production you need to configure the mailer to use a different adapter. 65 | # Also, you may need to configure the Swoosh API client of your choice if you 66 | # are not using SMTP. Here is an example of the configuration: 67 | # 68 | # config :swiphly, Swiphly.Mailer, 69 | # adapter: Swoosh.Adapters.Mailgun, 70 | # api_key: System.get_env("MAILGUN_API_KEY"), 71 | # domain: System.get_env("MAILGUN_DOMAIN") 72 | # 73 | # For this example you need include a HTTP client required by Swoosh API client. 74 | # Swoosh supports Hackney and Finch out of the box: 75 | # 76 | # config :swoosh, :api_client, Swoosh.ApiClient.Hackney 77 | # 78 | # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. 79 | end 80 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | # 5 | # The MIX_TEST_PARTITION environment variable can be used 6 | # to provide built-in test partitioning in CI environment. 7 | # Run `mix help test` for more information. 8 | config :swiphly, Swiphly.Repo, 9 | database: Path.expand("../swiphly_test.db", Path.dirname(__ENV__.file)), 10 | pool_size: 5, 11 | pool: Ecto.Adapters.SQL.Sandbox 12 | 13 | # We don't run a server during test. If one is required, 14 | # you can enable the server option below. 15 | config :swiphly, SwiphlyWeb.Endpoint, 16 | http: [ip: {127, 0, 0, 1}, port: 4002], 17 | secret_key_base: "FIy3nOIG7kIymIlimWfflQjYXa9AVD3V07xX8u/BPdGzEuDSB/lgGQRruaOEcTrf", 18 | server: false 19 | 20 | # In test we don't send emails. 21 | config :swiphly, Swiphly.Mailer, adapter: Swoosh.Adapters.Test 22 | 23 | # Print only warnings and errors during test 24 | config :logger, level: :warn 25 | 26 | # Initialize plugs at runtime for faster test compilation 27 | config :phoenix, :plug_init_mode, :runtime 28 | -------------------------------------------------------------------------------- /lib/swiphly.ex: -------------------------------------------------------------------------------- 1 | defmodule Swiphly do 2 | @moduledoc """ 3 | Swiphly 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/swiphly/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Swiphly.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 | Swiphly.Repo, 13 | # Start the Telemetry supervisor 14 | SwiphlyWeb.Telemetry, 15 | # Start the PubSub system 16 | {Phoenix.PubSub, name: Swiphly.PubSub}, 17 | # Start the Endpoint (http/https) 18 | SwiphlyWeb.Endpoint 19 | # Start a worker by calling: Swiphly.Worker.start_link(arg) 20 | # {Swiphly.Worker, arg} 21 | ] 22 | 23 | # See https://hexdocs.pm/elixir/Supervisor.html 24 | # for other strategies and supported options 25 | opts = [strategy: :one_for_one, name: Swiphly.Supervisor] 26 | Supervisor.start_link(children, opts) 27 | end 28 | 29 | # Tell Phoenix to update the endpoint configuration 30 | # whenever the application is updated. 31 | @impl true 32 | def config_change(changed, _new, removed) do 33 | SwiphlyWeb.Endpoint.config_change(changed, removed) 34 | :ok 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/swiphly/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule Swiphly.Mailer do 2 | use Swoosh.Mailer, otp_app: :swiphly 3 | end 4 | -------------------------------------------------------------------------------- /lib/swiphly/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Swiphly.Repo do 2 | use Ecto.Repo, 3 | otp_app: :swiphly, 4 | adapter: Ecto.Adapters.SQLite3 5 | end 6 | -------------------------------------------------------------------------------- /lib/swiphly/schema.ex: -------------------------------------------------------------------------------- 1 | # Define a module to be used as base 2 | defmodule Swiphly.Schema do 3 | # from https://stackoverflow.com/questions/58206597/how-to-set-datetime-in-ecto-schemas-and-timestamp-with-time-zone-timestamp 4 | defmacro __using__(_) do 5 | quote do 6 | use TypedEctoSchema 7 | 8 | # ------------------------------------ 9 | @timestamps_opts [type: :utc_datetime_usec] 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/swiphly/visitors.ex: -------------------------------------------------------------------------------- 1 | defmodule Swiphly.Visitors do 2 | @moduledoc """ 3 | The Visitors context. 4 | """ 5 | 6 | import Ecto.Query, warn: false 7 | alias Swiphly.Repo 8 | 9 | alias Swiphly.Visitors.Contact 10 | 11 | @doc """ 12 | Returns the list of contacts. 13 | 14 | ## Examples 15 | 16 | iex> list_contacts() 17 | [%Contact{}, ...] 18 | 19 | """ 20 | def list_contacts do 21 | Contact |> order_by(desc: :inserted_at) |> Repo.all() 22 | end 23 | 24 | @doc """ 25 | Gets a single contact. 26 | 27 | Raises `Ecto.NoResultsError` if the Contact does not exist. 28 | 29 | ## Examples 30 | 31 | iex> get_contact!(123) 32 | %Contact{} 33 | 34 | iex> get_contact!(456) 35 | ** (Ecto.NoResultsError) 36 | 37 | """ 38 | def get_contact!(id), do: Repo.get!(Contact, id) 39 | 40 | @doc """ 41 | Creates a contact. 42 | 43 | ## Examples 44 | 45 | iex> create_contact(%{field: value}) 46 | {:ok, %Contact{}} 47 | 48 | iex> create_contact(%{field: bad_value}) 49 | {:error, %Ecto.Changeset{}} 50 | 51 | """ 52 | def create_contact(attrs \\ %{}) do 53 | result = %Contact{} 54 | |> Contact.changeset(attrs) 55 | |> Repo.insert() 56 | 57 | case result do 58 | {:ok, contact} -> 59 | Phoenix.PubSub.broadcast!(Swiphly.PubSub, "contacts", {:new_contact, contact}) 60 | result 61 | _ -> result 62 | end 63 | end 64 | 65 | @doc """ 66 | Updates a contact. 67 | 68 | ## Examples 69 | 70 | iex> update_contact(contact, %{field: new_value}) 71 | {:ok, %Contact{}} 72 | 73 | iex> update_contact(contact, %{field: bad_value}) 74 | {:error, %Ecto.Changeset{}} 75 | 76 | """ 77 | def update_contact(%Contact{} = contact, attrs) do 78 | contact 79 | |> Contact.changeset(attrs) 80 | |> Repo.update() 81 | end 82 | 83 | @doc """ 84 | Deletes a contact. 85 | 86 | ## Examples 87 | 88 | iex> delete_contact(contact) 89 | {:ok, %Contact{}} 90 | 91 | iex> delete_contact(contact) 92 | {:error, %Ecto.Changeset{}} 93 | 94 | """ 95 | def delete_contact(%Contact{} = contact) do 96 | Repo.delete(contact) 97 | end 98 | 99 | @doc """ 100 | Returns an `%Ecto.Changeset{}` for tracking contact changes. 101 | 102 | ## Examples 103 | 104 | iex> change_contact(contact) 105 | %Ecto.Changeset{data: %Contact{}} 106 | 107 | """ 108 | def change_contact(%Contact{} = contact, attrs \\ %{}) do 109 | Contact.changeset(contact, attrs) 110 | end 111 | 112 | alias Swiphly.Visitors.Chat 113 | 114 | @doc """ 115 | Returns the list of chats. 116 | 117 | ## Examples 118 | 119 | iex> list_chats() 120 | [%Chat{}, ...] 121 | 122 | """ 123 | def list_chats do 124 | Chat |> order_by(desc: :inserted_at) |> Repo.all() 125 | end 126 | 127 | @doc """ 128 | Gets a single chat. 129 | 130 | Raises `Ecto.NoResultsError` if the Chat does not exist. 131 | 132 | ## Examples 133 | 134 | iex> get_chat!(123) 135 | %Chat{} 136 | 137 | iex> get_chat!(456) 138 | ** (Ecto.NoResultsError) 139 | 140 | """ 141 | def get_chat!(id), do: Repo.get!(Chat, id) 142 | 143 | @doc """ 144 | Creates a chat. 145 | 146 | ## Examples 147 | 148 | iex> create_chat(%{field: value}) 149 | {:ok, %Chat{}} 150 | 151 | iex> create_chat(%{field: bad_value}) 152 | {:error, %Ecto.Changeset{}} 153 | 154 | """ 155 | def create_chat(attrs \\ %{}) do 156 | result = %Chat{} 157 | |> Chat.changeset(attrs) 158 | |> Repo.insert() 159 | 160 | case result do 161 | {:ok, chat} -> 162 | Phoenix.PubSub.broadcast!(Swiphly.PubSub, "chats", {:new_chat, chat}) 163 | result 164 | _ -> result 165 | end 166 | end 167 | 168 | @doc """ 169 | Updates a chat. 170 | 171 | ## Examples 172 | 173 | iex> update_chat(chat, %{field: new_value}) 174 | {:ok, %Chat{}} 175 | 176 | iex> update_chat(chat, %{field: bad_value}) 177 | {:error, %Ecto.Changeset{}} 178 | 179 | """ 180 | def update_chat(%Chat{} = chat, attrs) do 181 | chat 182 | |> Chat.changeset(attrs) 183 | |> Repo.update() 184 | end 185 | 186 | @doc """ 187 | Deletes a chat. 188 | 189 | ## Examples 190 | 191 | iex> delete_chat(chat) 192 | {:ok, %Chat{}} 193 | 194 | iex> delete_chat(chat) 195 | {:error, %Ecto.Changeset{}} 196 | 197 | """ 198 | def delete_chat(%Chat{} = chat) do 199 | Repo.delete(chat) 200 | end 201 | 202 | @doc """ 203 | Returns an `%Ecto.Changeset{}` for tracking chat changes. 204 | 205 | ## Examples 206 | 207 | iex> change_chat(chat) 208 | %Ecto.Changeset{data: %Chat{}} 209 | 210 | """ 211 | def change_chat(%Chat{} = chat, attrs \\ %{}) do 212 | Chat.changeset(chat, attrs) 213 | end 214 | end 215 | -------------------------------------------------------------------------------- /lib/swiphly/visitors/chat.ex: -------------------------------------------------------------------------------- 1 | defmodule Swiphly.Visitors.Chat do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | # NOTE you would want to enforce references through ecto associations 6 | # We use a simpler approach here for demonstration purposes 7 | @derive {Jason.Encoder, only: [:id, :contact_id, :message, :inserted_at]} 8 | schema "chats" do 9 | field :contact_id, :integer 10 | field :message, :string 11 | 12 | timestamps() 13 | end 14 | 15 | @doc false 16 | def changeset(chat, attrs) do 17 | chat 18 | |> cast(attrs, [:contact_id, :message]) 19 | |> validate_required([:contact_id, :message]) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/swiphly/visitors/contact.ex: -------------------------------------------------------------------------------- 1 | defmodule Swiphly.Visitors.Contact do 2 | use Swiphly.Schema 3 | import Ecto.Changeset 4 | 5 | @derive {Jason.Encoder, only: [:id, :name, :company, :title, :event, :inserted_at]} 6 | schema "contacts" do 7 | field :company, :string 8 | field :event, :string 9 | field :name, :string 10 | field :title, :string 11 | 12 | timestamps() 13 | end 14 | 15 | @doc false 16 | def changeset(contact, attrs) do 17 | contact 18 | |> cast(attrs, [:name, :title, :company, :event]) 19 | |> validate_required([:name, :title, :company, :event]) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/swiphly_web.ex: -------------------------------------------------------------------------------- 1 | defmodule SwiphlyWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, views, channels and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use SwiphlyWeb, :controller 9 | use SwiphlyWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: SwiphlyWeb 23 | 24 | import Plug.Conn 25 | import SwiphlyWeb.Gettext 26 | alias SwiphlyWeb.Router.Helpers, as: Routes 27 | end 28 | end 29 | 30 | def view do 31 | quote do 32 | use Phoenix.View, 33 | root: "lib/swiphly_web/templates", 34 | namespace: SwiphlyWeb 35 | 36 | # Import convenience functions from controllers 37 | import Phoenix.Controller, 38 | only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] 39 | 40 | # Include shared imports and aliases for views 41 | unquote(view_helpers()) 42 | end 43 | end 44 | 45 | def live_view do 46 | quote do 47 | use Phoenix.LiveView, 48 | layout: {SwiphlyWeb.LayoutView, "live.html"} 49 | 50 | unquote(view_helpers()) 51 | end 52 | end 53 | 54 | def live_component do 55 | quote do 56 | use Phoenix.LiveComponent 57 | 58 | unquote(view_helpers()) 59 | end 60 | end 61 | 62 | def component do 63 | quote do 64 | use Phoenix.Component 65 | 66 | unquote(view_helpers()) 67 | end 68 | end 69 | 70 | def router do 71 | quote do 72 | use Phoenix.Router 73 | 74 | import Plug.Conn 75 | import Phoenix.Controller 76 | import Phoenix.LiveView.Router 77 | end 78 | end 79 | 80 | def channel do 81 | quote do 82 | use Phoenix.Channel 83 | import SwiphlyWeb.Gettext 84 | end 85 | end 86 | 87 | defp view_helpers do 88 | quote do 89 | # Use all HTML functionality (forms, tags, etc) 90 | use Phoenix.HTML 91 | 92 | # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) 93 | import Phoenix.LiveView.Helpers 94 | 95 | # Import basic rendering functionality (render, render_layout, etc) 96 | import Phoenix.View 97 | 98 | import SwiphlyWeb.ErrorHelpers 99 | import SwiphlyWeb.Gettext 100 | alias SwiphlyWeb.Router.Helpers, as: Routes 101 | end 102 | end 103 | 104 | @doc """ 105 | When used, dispatch to the appropriate controller/view/etc. 106 | """ 107 | defmacro __using__(which) when is_atom(which) do 108 | apply(__MODULE__, which, []) 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/swiphly_web/components/svelte_component.ex: -------------------------------------------------------------------------------- 1 | defmodule SwiphlyWeb.SvelteComponent do 2 | use SwiphlyWeb, :live_component 3 | require Logger 4 | 5 | def render(assigns) do 6 | ~H""" 7 | 8 | """ 9 | end 10 | 11 | defp json(nil), do: "" 12 | defp json(props) do 13 | case Jason.encode(props) do 14 | {:ok, data} -> data 15 | {:error, err} -> 16 | Logger.error("Could not JSON encode props: #{inspect err}") 17 | "" 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/swiphly_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule SwiphlyWeb.PageController do 2 | use SwiphlyWeb, :controller 3 | 4 | def index(conn, _params) do 5 | render(conn, "index.html") 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/swiphly_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule SwiphlyWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :swiphly 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: "_swiphly_key", 10 | signing_salt: "XIyvKJrw" 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: :swiphly, 22 | gzip: false, 23 | only: ~w(assets fonts images favicon.ico robots.txt) 24 | 25 | # Code reloading can be explicitly enabled under the 26 | # :code_reloader configuration of your endpoint. 27 | if code_reloading? do 28 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 29 | plug Phoenix.LiveReloader 30 | plug Phoenix.CodeReloader 31 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :swiphly 32 | end 33 | 34 | plug Phoenix.LiveDashboard.RequestLogger, 35 | param_key: "request_logger", 36 | cookie_key: "request_logger" 37 | 38 | plug Plug.RequestId 39 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 40 | 41 | plug Plug.Parsers, 42 | parsers: [:urlencoded, :multipart, :json], 43 | pass: ["*/*"], 44 | json_decoder: Phoenix.json_library() 45 | 46 | plug Plug.MethodOverride 47 | plug Plug.Head 48 | plug Plug.Session, @session_options 49 | plug SwiphlyWeb.Router 50 | end 51 | -------------------------------------------------------------------------------- /lib/swiphly_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule SwiphlyWeb.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 SwiphlyWeb.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, otp_app: :swiphly 24 | end 25 | -------------------------------------------------------------------------------- /lib/swiphly_web/live/end_to_end.ex: -------------------------------------------------------------------------------- 1 | defmodule SwiphlyWeb.EndToEnd do 2 | use SwiphlyWeb, :live_view 3 | 4 | alias Swiphly.Visitors 5 | 6 | require Logger 7 | 8 | @impl true 9 | def mount(_params, _session, socket) do 10 | Phoenix.PubSub.subscribe(Swiphly.PubSub, "contacts") 11 | Phoenix.PubSub.subscribe(Swiphly.PubSub, "chats") 12 | 13 | socket = 14 | socket 15 | |> assign(:contacts, Visitors.list_contacts()) 16 | |> assign(:last_chat, List.first(Visitors.list_chats())) 17 | 18 | {:ok, socket} 19 | end 20 | 21 | @impl true 22 | def render(assigns) do 23 | ~H""" 24 | <.live_component module={SwiphlyWeb.SvelteComponent} id="contacts" name="E2E" props={%{contacts: @contacts, last_chat: @last_chat}} /> 25 | """ 26 | end 27 | 28 | @impl true 29 | def handle_info({:new_contact, contact}, socket) do 30 | socket = 31 | socket 32 | |> assign(:contacts, [contact | socket.assigns.contacts]) 33 | 34 | {:noreply, socket} 35 | end 36 | 37 | @impl true 38 | def handle_info({:new_chat, chat}, socket) do 39 | socket = 40 | socket 41 | |> assign(:last_chat, chat) 42 | 43 | {:noreply, socket} 44 | end 45 | 46 | @impl true 47 | def handle_event("create", params, socket) do 48 | case Visitors.create_chat(params) do 49 | {:ok, chat} -> 50 | socket = 51 | socket 52 | |> assign(:last_chat, chat) 53 | 54 | {:reply, %{success: true}, socket} 55 | 56 | {:error, changeset} -> 57 | error = 58 | changeset.errors 59 | |> Enum.map(fn {field, {failure, _}} -> "#{field} #{failure}" end) 60 | |> Enum.join(", ") 61 | 62 | {:reply, %{success: false, reason: error}, socket} 63 | 64 | _ -> 65 | {:reply, %{success: false}, socket} 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/swiphly_web/live/no_event.ex: -------------------------------------------------------------------------------- 1 | defmodule SwiphlyWeb.NoEvent do 2 | use SwiphlyWeb, :live_view 3 | 4 | alias Swiphly.Visitors 5 | 6 | require Logger 7 | 8 | @impl true 9 | def mount(_params, _session, socket) do 10 | socket = 11 | socket 12 | |> assign(:contacts, Visitors.list_contacts()) 13 | 14 | {:ok, socket} 15 | end 16 | 17 | @impl true 18 | def render(assigns) do 19 | ~H""" 20 | <.live_component module={SwiphlyWeb.SvelteComponent} id="contacts" name="NoEvent" props={%{contacts: @contacts}} /> 21 | """ 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/swiphly_web/live/patch_event.ex: -------------------------------------------------------------------------------- 1 | defmodule SwiphlyWeb.PatchEvent do 2 | use SwiphlyWeb, :live_view 3 | 4 | alias Swiphly.Visitors 5 | 6 | require Logger 7 | 8 | @impl true 9 | def mount(_params, _session, socket) do 10 | sort = "name" 11 | 12 | socket = 13 | socket 14 | |> assign(:contacts, sort_contacts(Visitors.list_contacts(), sort)) 15 | |> assign(:selected, nil) 16 | |> assign(:sort, sort) 17 | 18 | {:ok, socket} 19 | end 20 | 21 | @impl true 22 | def render(assigns) do 23 | ~H""" 24 | <.live_component module={SwiphlyWeb.SvelteComponent} id="contacts" name="ContactList" props={%{contacts: @contacts, selected: @selected, sort: @sort}} /> 25 | """ 26 | end 27 | 28 | @impl true 29 | def handle_params(%{"contact" => contact_id, "sort" => sort}, _uri, socket) do 30 | {contact_id, _} = Integer.parse(contact_id) 31 | selected = Enum.find(socket.assigns.contacts, fn %{id: id} -> id == contact_id end) 32 | 33 | socket = 34 | socket 35 | |> assign(:contacts, sort_contacts(socket.assigns.contacts, sort)) 36 | |> assign(:sort, sort) 37 | |> assign(:selected, selected) 38 | 39 | {:noreply, socket} 40 | end 41 | 42 | @impl true 43 | def handle_params(%{"contact" => contact_id}, _uri, socket) do 44 | {contact_id, _} = Integer.parse(contact_id) 45 | selected = Enum.find(socket.assigns.contacts, fn %{id: id} -> id == contact_id end) 46 | 47 | socket = 48 | socket 49 | |> assign(:selected, selected) 50 | 51 | {:noreply, socket} 52 | end 53 | 54 | @impl true 55 | def handle_params(%{"sort" => sort}, _uri, socket) do 56 | socket = 57 | socket 58 | |> assign(:contacts, sort_contacts(socket.assigns.contacts, sort)) 59 | |> assign(:sort, sort) 60 | 61 | {:noreply, socket} 62 | end 63 | 64 | @impl true 65 | def handle_params(_params, _uri, socket) do 66 | socket = 67 | socket 68 | |> assign(:selected, nil) 69 | 70 | {:noreply, socket} 71 | end 72 | 73 | defp sort_contacts(contacts, sort) do 74 | case sort do 75 | "name" -> 76 | Enum.sort_by(contacts, & &1.name) 77 | 78 | "created" -> 79 | Enum.sort_by(contacts, & &1.inserted_at, DateTime) 80 | 81 | _ -> 82 | contacts 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/swiphly_web/live/push_event.ex: -------------------------------------------------------------------------------- 1 | defmodule SwiphlyWeb.PushEvent do 2 | use SwiphlyWeb, :live_view 3 | 4 | alias Swiphly.Visitors 5 | 6 | @impl true 7 | def mount(_params, _session, socket) do 8 | socket = 9 | socket 10 | |> assign(:contacts, Visitors.list_contacts()) 11 | 12 | {:ok, socket} 13 | end 14 | 15 | @impl true 16 | def render(assigns) do 17 | ~H""" 18 | <.live_component module={SwiphlyWeb.SvelteComponent} id="contacts" name="ContactForm" props={%{contacts: @contacts}} /> 19 | """ 20 | end 21 | 22 | @impl true 23 | def handle_event("create", params, %{assigns: %{contacts: contacts}} = socket) do 24 | case Visitors.create_contact(params) do 25 | {:ok, contact} -> 26 | socket = 27 | socket 28 | |> assign(:contacts, [contact] ++ contacts) 29 | 30 | {:reply, %{success: true}, socket} 31 | 32 | {:error, changeset} -> 33 | error = 34 | changeset.errors 35 | |> Enum.map(fn {field, {failure, _}} -> "#{field} #{failure}" end) 36 | |> Enum.join(", ") 37 | 38 | {:reply, %{success: false, reason: error}, socket} 39 | 40 | _ -> 41 | {:reply, %{success: false}, socket} 42 | end 43 | end 44 | 45 | @impl true 46 | def handle_event("delete", %{"contact_id" => id}, %{assigns: %{contacts: contacts}} = socket) do 47 | with contact <- Visitors.get_contact!(id), 48 | {:ok, _} <- Visitors.delete_contact(contact) do 49 | socket = 50 | socket 51 | |> assign(:contacts, Enum.reject(contacts, fn c -> c.id == id end)) 52 | 53 | {:noreply, socket} 54 | else 55 | _ -> 56 | {:noreply, socket} 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/swiphly_web/live/simple_svelte_component.ex: -------------------------------------------------------------------------------- 1 | defmodule SwiphlyWeb.SimpleSvelteComponent do 2 | use SwiphlyWeb, :live_view 3 | 4 | def mount(_params, _session, socket) do 5 | {:ok, assign(socket, content: "content from a socket assigns, loaded as a prop")} 6 | end 7 | 8 | def render(assigns) do 9 | ~H""" 10 | <.live_component module={SwiphlyWeb.SvelteComponent} id="hero" name="HelloSvelte" props={%{content: @content}} /> 11 | """ 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/swiphly_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule SwiphlyWeb.Router do 2 | use SwiphlyWeb, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_live_flash 8 | plug :put_root_layout, {SwiphlyWeb.LayoutView, :root} 9 | plug :protect_from_forgery 10 | plug :put_secure_browser_headers 11 | end 12 | 13 | pipeline :api do 14 | plug :accepts, ["json"] 15 | end 16 | 17 | scope "/", SwiphlyWeb do 18 | pipe_through :browser 19 | 20 | get "/", PageController, :index 21 | live "/simple-svelte-component", SimpleSvelteComponent 22 | live "/push-event", PushEvent 23 | live "/patch-event", PatchEvent 24 | live "/no-event", NoEvent 25 | live "/e-2-e", EndToEnd 26 | end 27 | 28 | # Other scopes may use custom stacks. 29 | # scope "/api", SwiphlyWeb do 30 | # pipe_through :api 31 | # end 32 | 33 | # Enables LiveDashboard only for development 34 | # 35 | # If you want to use the LiveDashboard in production, you should put 36 | # it behind authentication and allow only admins to access it. 37 | # If your application does not have an admins-only section yet, 38 | # you can use Plug.BasicAuth to set up some basic authentication 39 | # as long as you are also using SSL (which you should anyway). 40 | if Mix.env() in [:dev, :test] do 41 | import Phoenix.LiveDashboard.Router 42 | 43 | scope "/" do 44 | pipe_through :browser 45 | 46 | live_dashboard "/dashboard", metrics: SwiphlyWeb.Telemetry 47 | end 48 | end 49 | 50 | # Enables the Swoosh mailbox preview in development. 51 | # 52 | # Note that preview only shows emails that were sent by the same 53 | # node running the Phoenix server. 54 | if Mix.env() == :dev do 55 | scope "/dev" do 56 | pipe_through :browser 57 | 58 | forward "/mailbox", Plug.Swoosh.MailboxPreview 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/swiphly_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule SwiphlyWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.stop.duration", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.router_dispatch.stop.duration", 29 | tags: [:route], 30 | unit: {:native, :millisecond} 31 | ), 32 | 33 | # Database Metrics 34 | summary("swiphly.repo.query.total_time", 35 | unit: {:native, :millisecond}, 36 | description: "The sum of the other measurements" 37 | ), 38 | summary("swiphly.repo.query.decode_time", 39 | unit: {:native, :millisecond}, 40 | description: "The time spent decoding the data received from the database" 41 | ), 42 | summary("swiphly.repo.query.query_time", 43 | unit: {:native, :millisecond}, 44 | description: "The time spent executing the query" 45 | ), 46 | summary("swiphly.repo.query.queue_time", 47 | unit: {:native, :millisecond}, 48 | description: "The time spent waiting for a database connection" 49 | ), 50 | summary("swiphly.repo.query.idle_time", 51 | unit: {:native, :millisecond}, 52 | description: 53 | "The time the connection spent waiting before being checked out for the query" 54 | ), 55 | 56 | # VM Metrics 57 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 58 | summary("vm.total_run_queue_lengths.total"), 59 | summary("vm.total_run_queue_lengths.cpu"), 60 | summary("vm.total_run_queue_lengths.io") 61 | ] 62 | end 63 | 64 | defp periodic_measurements do 65 | [ 66 | # A module, function and arguments to be invoked periodically. 67 | # This function must call :telemetry.execute/3 and a metric must be added above. 68 | # {SwiphlyWeb, :count_users, []} 69 | ] 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/swiphly_web/templates/layout/app.html.heex: -------------------------------------------------------------------------------- 1 |<%= get_flash(@conn, :info) %>
3 |<%= get_flash(@conn, :error) %>
4 | <%= @inner_content %> 5 |<%= live_flash(@flash, :info) %>
5 | 6 |<%= live_flash(@flash, :error) %>
9 | 10 | <%= @inner_content %> 11 |Peace of mind from prototype to production
4 |