mirror of
https://github.com/ryan-haskell/elm-spa.git
synced 2024-09-11 12:35:44 +03:00
prepare for v5
This commit is contained in:
parent
255a25ab3f
commit
d12d183889
28
.github/workflows/nodejs.yml
vendored
Normal file
28
.github/workflows/nodejs.yml
vendored
Normal file
@ -0,0 +1,28 @@
|
||||
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [12.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: npm ci
|
||||
- run: npm run test:ci
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -2,4 +2,7 @@
|
||||
elm-stuff
|
||||
node_modules
|
||||
dist
|
||||
Generated
|
||||
Generated
|
||||
elm-spa-*.tgz
|
||||
# Local Netlify folder
|
||||
.netlify
|
10
.npmignore
Normal file
10
.npmignore
Normal file
@ -0,0 +1,10 @@
|
||||
elm-stuff
|
||||
/src
|
||||
/tests
|
||||
/elm.json
|
||||
/examples
|
||||
!.gitignore
|
||||
.github
|
||||
.netlify
|
||||
*.tgz
|
||||
/dist/elm.js
|
@ -1,3 +0,0 @@
|
||||
language: elm
|
||||
before_install: cd cli
|
||||
script: elm-format --validate src tests && elm-test
|
161
README.md
161
README.md
@ -1,161 +1,12 @@
|
||||
# elm-spa
|
||||
# elm-spa
|
||||
|
||||
![Build](https://github.com/ryannhg/elm-spa/workflows/Build/badge.svg?branch=master)
|
||||
|
||||
[![Build Status](https://travis-ci.org/ryannhg/elm-spa.svg?branch=master)](https://travis-ci.org/ryannhg/elm-spa)
|
||||
|
||||
## single page apps made easy
|
||||
|
||||
When you create an app with the [elm/browser](https://package.elm-lang.org/packages/elm/browser/latest) package, you can build anything from a static `Html msg` page to a fully-fledged web `Browser.application`.
|
||||
|
||||
__elm-spa__ uses that design at the page-level, so you can quickly add new pages to your Elm application!
|
||||
|
||||
✅ Automatically generate routes and pages
|
||||
|
||||
✅ Read and update global state across pages
|
||||
|
||||
## static pages
|
||||
|
||||
```elm
|
||||
-- can render a static page
|
||||
page : Page Flags Model Msg
|
||||
page =
|
||||
Page.static
|
||||
{ view = view
|
||||
}
|
||||
```
|
||||
npm install -g elm-spa@latest
|
||||
```
|
||||
|
||||
## sandbox pages
|
||||
|
||||
```elm
|
||||
-- can keep track of page state
|
||||
page : Page Flags Model Msg
|
||||
page =
|
||||
Page.sandbox
|
||||
{ init = init
|
||||
, update = update
|
||||
, view = view
|
||||
}
|
||||
```
|
||||
|
||||
## element pages
|
||||
|
||||
```elm
|
||||
-- can perform side effects
|
||||
page : Page Flags Model Msg
|
||||
page =
|
||||
Page.element
|
||||
{ init = init
|
||||
, update = update
|
||||
, subscriptions = subscriptions
|
||||
, view = view
|
||||
}
|
||||
```
|
||||
|
||||
## component pages
|
||||
|
||||
```elm
|
||||
-- can read and update global state
|
||||
page : Page Flags Model Msg
|
||||
page =
|
||||
Page.component
|
||||
{ init = init
|
||||
, update = update
|
||||
, subscriptions = subscriptions
|
||||
, view = view
|
||||
}
|
||||
```
|
||||
|
||||
## easily put together pages!
|
||||
|
||||
The reason we return the same `Page` type is to make it super
|
||||
easy to write top-level `init`, `update`, `view`, and `susbcriptions` functions.
|
||||
|
||||
(And if you're using the [official cli tool](https://npmjs.org/elm-spa), this code will be automatically generated for you)
|
||||
|
||||
### `init`
|
||||
|
||||
```elm
|
||||
init : Route -> Global.Model -> ( Model, Cmd Msg, Cmd Global.Msg )
|
||||
init route =
|
||||
case route of
|
||||
Route.Home -> pages.home.init ()
|
||||
Route.About -> pages.about.init ()
|
||||
Route.Posts slug -> pages.posts.init slug
|
||||
Route.SignIn -> pages.signIn.init ()
|
||||
```
|
||||
|
||||
### `update`
|
||||
|
||||
```elm
|
||||
update : Msg -> Model -> Global.Model -> ( Model, Cmd Msg, Cmd Global.Msg )
|
||||
update bigMsg bigModel =
|
||||
case ( bigMsg, bigModel ) of
|
||||
( Home_Msg msg, Home_Model model ) ->
|
||||
pages.home.update msg model
|
||||
|
||||
( About_Msg msg, About_Model model ) ->
|
||||
pages.about.update msg model
|
||||
|
||||
( Posts_Msg msg, Posts_Model model ) ->
|
||||
pages.posts.update msg model
|
||||
|
||||
( SignIn_Msg msg, SignIn_Model model ) ->
|
||||
pages.signIn.update msg model
|
||||
|
||||
_ ->
|
||||
always ( bigModel, Cmd.none, Cmd.none )
|
||||
```
|
||||
|
||||
### `view` + `subscriptions`
|
||||
|
||||
```elm
|
||||
-- handle view and subscriptions in one case expression!
|
||||
bundle : Model -> Global.Model -> { view : Document Msg, subscriptions : Sub Msg }
|
||||
bundle bigModel =
|
||||
case route of
|
||||
Home_Model model -> pages.home.bundle model
|
||||
About_Model model -> pages.about.bundle model
|
||||
Posts_Model model -> pages.posts.bundle model
|
||||
SignIn_Model model -> pages.signIn.bundle model
|
||||
```
|
||||
|
||||
### install the npm package
|
||||
|
||||
The [cli tool](https://www.npmjs.com/package/elm-spa) has commands like `elm-spa init`, `elm-spa add`, and `elm-spa build` for
|
||||
generating your routes and pages for you!
|
||||
|
||||
```
|
||||
npm install -g elm-spa
|
||||
elm-spa init new-project
|
||||
```
|
||||
|
||||
### install the elm package
|
||||
|
||||
If you'd rather define routes and pages by hand,
|
||||
you can add [the elm package](https://package.elm-lang.org/packages/ryannhg/elm-spa/latest) to your project:
|
||||
|
||||
```
|
||||
elm install ryannhg/elm-spa
|
||||
```
|
||||
|
||||
### rather see an example?
|
||||
|
||||
This repo comes with an example project that you can
|
||||
play around with. Add in some pages and see how it works!
|
||||
|
||||
#### html example
|
||||
|
||||
```
|
||||
git clone https://github.com/ryannhg/elm-spa
|
||||
cd elm-spa/examples/html
|
||||
npm start
|
||||
```
|
||||
|
||||
#### elm-ui example
|
||||
|
||||
```
|
||||
git clone https://github.com/ryannhg/elm-spa
|
||||
cd elm-spa/examples/elm-ui
|
||||
npm start
|
||||
```
|
||||
|
||||
The __elm-spa__ will be running at http://localhost:8000
|
||||
Learn more at [the offical guide](https://elm-spa.dev/guide)!
|
@ -1,5 +0,0 @@
|
||||
elm-stuff
|
||||
/src
|
||||
/tests
|
||||
/elm.json
|
||||
!.gitignore
|
144
cli/README.md
144
cli/README.md
@ -1,144 +0,0 @@
|
||||
# elm-spa
|
||||
|
||||
[![Build Status](https://travis-ci.org/ryannhg/elm-spa.svg?branch=master)](https://travis-ci.org/ryannhg/elm-spa)
|
||||
|
||||
## single page apps made easy
|
||||
|
||||
this is the cli tool for [the ryannhg/elm-spa package](https://package.elm-lang.org/packages/ryannhg/elm-spa/latest).
|
||||
|
||||
It comes with a few commands to help you build single page applications in Elm!
|
||||
|
||||
## installation
|
||||
|
||||
```
|
||||
npm install -g elm-spa
|
||||
```
|
||||
|
||||
## available commands
|
||||
|
||||
- [elm-spa init](#elm-spa-init) – create a new project
|
||||
- [elm-spa add](#elm-spa-add) – add a new page
|
||||
- [elm-spa build](#elm-spa-build) – generate routes and pages
|
||||
|
||||
|
||||
## elm-spa init
|
||||
|
||||
```
|
||||
elm-spa init <directory>
|
||||
|
||||
Create a new elm-spa app in the <directory>
|
||||
folder specified.
|
||||
|
||||
examples:
|
||||
elm-spa init .
|
||||
elm-spa init my-app
|
||||
```
|
||||
|
||||
## elm-spa add
|
||||
|
||||
```
|
||||
elm-spa add <static|sandbox|element|component> <name>
|
||||
|
||||
Create a new page of type <static|sandbox|element|component>
|
||||
with the module name <name>.
|
||||
|
||||
examples:
|
||||
elm-spa add static Top
|
||||
elm-spa add sandbox Posts.Top
|
||||
elm-spa add element Posts.Dynamic
|
||||
elm-spa add component SignIn
|
||||
```
|
||||
|
||||
## elm-spa build
|
||||
|
||||
```
|
||||
elm-spa build [dir]
|
||||
|
||||
Create "Generated.Route" and "Generated.Pages" modules for
|
||||
this project, based on the files in "src/Pages"
|
||||
|
||||
Here are more details on how that works:
|
||||
https://www.npmjs.com/package/elm-spa#naming-conventions
|
||||
|
||||
examples:
|
||||
elm-spa build
|
||||
elm-spa build ../some/other-folder
|
||||
elm-spa build ./help
|
||||
```
|
||||
|
||||
## naming conventions
|
||||
|
||||
the `elm-spa build` command is pretty useful, because it
|
||||
automatically generates `Routes.elm` and `Pages.elm` code for you,
|
||||
based on the naming convention in `src/Pages/*.elm`
|
||||
|
||||
Here's an example project structure:
|
||||
|
||||
```
|
||||
src/
|
||||
└─ Pages/
|
||||
├─ Top.elm
|
||||
├─ About.elm
|
||||
├─ Posts/
|
||||
| ├─ Top.elm
|
||||
| └─ Dynamic.elm
|
||||
└─ Authors/
|
||||
└─ Dynamic/
|
||||
└─ Posts/
|
||||
└─ Dynamic.elm
|
||||
```
|
||||
|
||||
When you run `elm-spa build` with these files in the `src/Pages` directory, __elm-spa__ can
|
||||
automatically generate routes like these:
|
||||
|
||||
__Page__ | __Route__ | __Example__
|
||||
:-- | :-- | :--
|
||||
`Top.elm` | `/` | -
|
||||
`About.elm` | `/about` | -
|
||||
`Posts/Top.elm` | `/posts` | -
|
||||
`Posts/Dynamic.elm` | `/posts/:param1` | `/posts/123`
|
||||
`Authors/Dynamic/Posts/Dynamic.elm` | `/authors/:param1/posts/:param2` | `/authors/ryan/posts/123`
|
||||
|
||||
### top-level and dynamic routes
|
||||
|
||||
- `Top.elm` represents the top-level index in the folder.
|
||||
- `Dynamic.elm` means that a dynamic parameter should match there.
|
||||
- `Dynamic` can also be used as a folder, supporting nested dynamic routes.
|
||||
|
||||
### accessing url parameters
|
||||
|
||||
These dynamic parameters are available as `Flags` for the given page.
|
||||
|
||||
Here are some specific examples from the routes above:
|
||||
|
||||
```elm
|
||||
module Pages.About exposing (..)
|
||||
|
||||
type alias Flags =
|
||||
()
|
||||
```
|
||||
|
||||
```elm
|
||||
module Pages.Posts.Dynamic exposing (..)
|
||||
|
||||
type alias Flags =
|
||||
{ param1 : String
|
||||
}
|
||||
```
|
||||
|
||||
```elm
|
||||
module Pages.Authors.Dynamic.Posts.Dynamic exposing (..)
|
||||
|
||||
type alias Flags =
|
||||
{ param1 : String
|
||||
, param2 : String
|
||||
}
|
||||
```
|
||||
|
||||
These `Flags` are automatically passed in to the `init` function of any `element` or `component` page.
|
||||
|
||||
|
||||
## the elm package
|
||||
|
||||
Need more details? Feel free to check out the [official elm package documentation](https://package.elm-lang.org/packages/ryannhg/elm-spa/latest)!
|
||||
|
192
cli/index.js
192
cli/index.js
@ -1,192 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const { Elm } = require('./dist/elm.worker.js')
|
||||
const package = require('./package.json')
|
||||
|
||||
// File stuff
|
||||
const folders = {
|
||||
src: (dir) => path.join(process.cwd(), dir, 'src'),
|
||||
pages: (dir) => path.join(process.cwd(), dir, 'src', 'Pages'),
|
||||
generated: (dir) => path.join(process.cwd(), dir, 'src', 'Generated')
|
||||
}
|
||||
|
||||
const rejectIfMissing = (dir) => new Promise((resolve, reject) =>
|
||||
fs.existsSync(dir) ? resolve(true) : reject(false)
|
||||
)
|
||||
|
||||
const cp = (src, dest) => {
|
||||
const exists = fs.existsSync(src)
|
||||
const stats = exists && fs.statSync(src)
|
||||
if (stats && stats.isDirectory()) {
|
||||
fs.mkdirSync(dest)
|
||||
fs.readdirSync(src).forEach(child =>
|
||||
cp(path.join(src, child), path.join(dest, child))
|
||||
)
|
||||
} else {
|
||||
fs.copyFileSync(src, dest)
|
||||
}
|
||||
}
|
||||
|
||||
const listFiles = (dir) =>
|
||||
fs.readdirSync(dir)
|
||||
.reduce((files, file) =>
|
||||
fs.statSync(path.join(dir, file)).isDirectory() ?
|
||||
files.concat(listFiles(path.join(dir, file))) :
|
||||
files.concat(path.join(dir, file)),
|
||||
[])
|
||||
|
||||
const ensureDirectory = (dir) =>
|
||||
fs.existsSync(dir) || fs.mkdirSync(dir, { recursive: true })
|
||||
|
||||
const saveToFolder = (prefix) => ({ filepath, content }) =>
|
||||
fs.writeFileSync(path.join(prefix, filepath), content, { encoding: 'utf8' })
|
||||
|
||||
// Formatting output
|
||||
const bold = (str) => '\033[1m' + str + '\033[0m'
|
||||
const toFilepath = name => path.join(folders.pages('.'), `${name.split('.').join('/')}.elm`)
|
||||
|
||||
// Flags + Validation
|
||||
const flags = { command: '', name: '', pageType: '', filepaths: [] }
|
||||
|
||||
const isValidPageType = type =>
|
||||
[ 'static', 'sandbox', 'element', 'component' ].some(x => x === type)
|
||||
|
||||
const isValidModuleName = (name = '') => {
|
||||
const isAlphaOnly = word => word.match(/[A-Z|a-z]+/)[0] === word
|
||||
const isCapitalized = word => word[0].toUpperCase() === word[0]
|
||||
return name &&
|
||||
name.length &&
|
||||
name.split('.').every(word => isAlphaOnly(word) && isCapitalized(word))
|
||||
}
|
||||
|
||||
// Help commands
|
||||
const help = {
|
||||
general: `
|
||||
${bold('elm-spa')} – version ${package.version}
|
||||
|
||||
${bold('elm-spa init')} – create a new project
|
||||
${bold('elm-spa add')} – add a new page
|
||||
${bold('elm-spa build')} – generate routes and pages automatically
|
||||
|
||||
${bold('elm-spa <command> help')} – get detailed help for a command
|
||||
${bold('elm-spa -v')} – print version number
|
||||
`,
|
||||
|
||||
init: `
|
||||
${bold('elm-spa init')} <directory>
|
||||
|
||||
Create a new elm-spa app in the <directory>
|
||||
folder specified.
|
||||
|
||||
${bold('examples:')}
|
||||
elm-spa init .
|
||||
elm-spa init my-app
|
||||
`,
|
||||
|
||||
add: `
|
||||
${bold('elm-spa add')} <static|sandbox|element|component> <name>
|
||||
|
||||
Create a new page of type <static|sandbox|element|component>
|
||||
with the module name <name>.
|
||||
|
||||
${bold('examples:')}
|
||||
elm-spa add static Top
|
||||
elm-spa add sandbox Posts.Top
|
||||
elm-spa add element Posts.Dynamic
|
||||
elm-spa add component SignIn
|
||||
`,
|
||||
|
||||
build: `
|
||||
${bold('elm-spa build')} [dir]
|
||||
|
||||
Create "Generated.Route" and "Generated.Pages" modules for
|
||||
this project, based on the files in "src/Pages"
|
||||
|
||||
Here are more details on how that works:
|
||||
https://www.npmjs.com/package/elm-spa#naming-conventions
|
||||
|
||||
${bold('examples:')}
|
||||
elm-spa build
|
||||
elm-spa build ../some/other-folder
|
||||
elm-spa build ./help
|
||||
`
|
||||
|
||||
}
|
||||
|
||||
const toUnixFilepath = (filepath) =>
|
||||
filepath.split(path.sep).join('/')
|
||||
|
||||
// Available commands
|
||||
const commands = {
|
||||
|
||||
'init': ([ folder ]) =>
|
||||
folder && folder !== 'help'
|
||||
? Promise.resolve()
|
||||
.then(_ => {
|
||||
const dest = path.join(process.cwd(), folder)
|
||||
cp(path.join(__dirname, 'projects', 'new'), dest)
|
||||
try { fs.renameSync(path.join(dest, '.npmignore'), path.join(dest, '.gitignore')) } catch (_) {}
|
||||
})
|
||||
.then(_ => `\ncreated a new project in ${path.join(process.cwd(), folder)}\n`)
|
||||
.catch(_ => `\nUnable to initialize a project at ${path.join(process.cwd(), folder)}\n`)
|
||||
: Promise.resolve(help.init),
|
||||
|
||||
'add': ([ type, name ]) =>
|
||||
(type && name) && type !== 'help' && isValidPageType(type) && isValidModuleName(name)
|
||||
? rejectIfMissing(folders.pages('.'))
|
||||
.then(_ => new Promise(
|
||||
Elm.Main.init({ flags: { ...flags, command: 'add', name: name, pageType: type } }).ports.addPort.subscribe)
|
||||
)
|
||||
.then(file => {
|
||||
const containingFolder = path.join(folders.pages('.'), file.filepath.split('/').slice(0, -1).join('/'))
|
||||
ensureDirectory(containingFolder)
|
||||
saveToFolder((folders.pages('.')))(file)
|
||||
})
|
||||
.then(_ => `\nadded a new ${bold(type)} page at:\n${toFilepath(name)}\n`)
|
||||
.catch(_ => `\nplease run ${bold('elm-spa add')} in the folder with ${bold('elm.json')}\n`)
|
||||
: Promise.resolve(help.add),
|
||||
|
||||
'build': ([ dir = '.' ] = []) =>
|
||||
dir !== 'help'
|
||||
? Promise.resolve(folders.pages(dir))
|
||||
.then(listFiles)
|
||||
.then(names => names.filter(name => name.endsWith('.elm')))
|
||||
.then(names => names.map(name => name.substring(folders.pages(dir).length)))
|
||||
.then(filepaths => new Promise(
|
||||
Elm.Main.init({ flags: { ...flags, command: 'build', filepaths: filepaths.map(toUnixFilepath) } }).ports.buildPort.subscribe
|
||||
))
|
||||
.then(files => {
|
||||
ensureDirectory(folders.generated(dir))
|
||||
files.forEach(saveToFolder(folders.src(dir)))
|
||||
return files
|
||||
})
|
||||
.then(files => `\nelm-spa generated two files:\n${files.map(({ filepath }) => ' - ' + path.join(folders.src(dir), filepath)).join('\n')}\n`)
|
||||
.catch(_ => `\nplease run ${bold('elm-spa build')} in the folder with ${bold('elm.json')}\n`)
|
||||
: Promise.resolve(help.build),
|
||||
|
||||
'-v': _ => Promise.resolve(package.version),
|
||||
|
||||
'help': _ => Promise.resolve(help.general)
|
||||
|
||||
}
|
||||
|
||||
const main = ([ command, ...args ] = []) =>
|
||||
(commands[command] || commands['help'])(args)
|
||||
// .then(_ => args.data.slice)
|
||||
.then(console.info)
|
||||
.catch(reason => {
|
||||
console.info(`\n${bold('Congratulations!')} - you've found a bug!
|
||||
|
||||
If you'd like, open an issue here with the following output:
|
||||
https://github.com/ryannhg/elm-spa/issues/new?labels=cli
|
||||
|
||||
|
||||
${bold(`### terminal output`)}
|
||||
`)
|
||||
console.log('```')
|
||||
console.error(reason)
|
||||
console.log('```\n')
|
||||
})
|
||||
|
||||
main([...process.argv.slice(2)])
|
@ -1,33 +0,0 @@
|
||||
{
|
||||
"name": "elm-spa",
|
||||
"version": "4.1.1",
|
||||
"description": "single page apps made easy",
|
||||
"main": "index.js",
|
||||
"bin": "./index.js",
|
||||
"directories": {
|
||||
"test": "tests"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "elm make src/Main.elm --optimize --output dist/elm.worker.js",
|
||||
"test": "elm-test",
|
||||
"test:watch": "elm-test --watch",
|
||||
"dev": "chokidar src -c \"(npm run build || true)\""
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/ryannhg/elm-spa.git"
|
||||
},
|
||||
"keywords": [
|
||||
"elm",
|
||||
"spa",
|
||||
"web",
|
||||
"framework"
|
||||
],
|
||||
"author": "Ryan Haskell-Glatz",
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://github.com/ryannhg/elm-spa/issues"
|
||||
},
|
||||
"homepage": "https://github.com/ryannhg/elm-spa#readme",
|
||||
"dependencies": {}
|
||||
}
|
6
cli/projects/new/.gitignore
vendored
6
cli/projects/new/.gitignore
vendored
@ -1,6 +0,0 @@
|
||||
.DS_Store
|
||||
dist
|
||||
elm-stuff
|
||||
node_modules
|
||||
|
||||
src/Generated
|
@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "elm-spa-app",
|
||||
"version": "1.0.0",
|
||||
"description": "my new elm-spa application",
|
||||
"main": "public/index.html",
|
||||
"scripts": {
|
||||
"start": "npm install && npm run build && npm run dev",
|
||||
"build": "npm run build:elm-spa && npm run build:elm",
|
||||
"build:elm-spa": "elm-spa build .",
|
||||
"build:elm": "elm make src/Main.elm --optimize --output public/dist/elm.js",
|
||||
"dev": "concurrently --raw --kill-others \"npm run dev:elm-spa\" \"npm run dev:elm\"",
|
||||
"dev:elm-spa": "chokidar src/Pages -c \"npm run build:elm-spa\"",
|
||||
"dev:elm": "elm-live src/Main.elm -u -d public -- --debug --output public/dist/elm.js"
|
||||
},
|
||||
"keywords": [
|
||||
"elm",
|
||||
"spa"
|
||||
],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"chokidar-cli": "2.1.0",
|
||||
"concurrently": "5.0.0",
|
||||
"elm": "0.19.1-3",
|
||||
"elm-live": "4.0.2",
|
||||
"elm-spa": "4.1.1"
|
||||
}
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="https://not-much-css.netlify.com/not-much.css" />
|
||||
<title>elm-spa</title>
|
||||
</head>
|
||||
<body>
|
||||
<script src="/dist/elm.js"></script>
|
||||
<script>
|
||||
Elm.Main.init()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@ -1,36 +0,0 @@
|
||||
module Components exposing (layout)
|
||||
|
||||
import Browser exposing (Document)
|
||||
import Generated.Route as Route exposing (Route)
|
||||
import Html exposing (..)
|
||||
import Html.Attributes as Attr exposing (class, href, style)
|
||||
|
||||
|
||||
layout : { page : Document msg } -> Document msg
|
||||
layout { page } =
|
||||
{ title = page.title
|
||||
, body =
|
||||
[ div [ class "column spacing--large pad--medium container h--fill" ]
|
||||
[ navbar
|
||||
, div [ class "column", style "flex" "1 0 auto" ] page.body
|
||||
, footer
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
navbar : Html msg
|
||||
navbar =
|
||||
header [ class "row center-y spacing--between" ]
|
||||
[ a [ class "link font--h5", href (Route.toHref Route.Top) ] [ text "home" ]
|
||||
, div [ class "row center-y spacing--medium" ]
|
||||
[ a [ class "link", href (Route.toHref Route.Docs) ] [ text "docs" ]
|
||||
, a [ class "link", href (Route.toHref Route.NotFound) ] [ text "a broken link" ]
|
||||
, a [ class "button", href "https://twitter.com/intent/tweet?text=elm-spa is ez pz" ] [ text "tweet about it" ]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
footer : Html msg
|
||||
footer =
|
||||
Html.footer [] [ text "built with elm ❤" ]
|
@ -1,98 +0,0 @@
|
||||
module Global exposing
|
||||
( Flags
|
||||
, Model
|
||||
, Msg
|
||||
, init
|
||||
, navigate
|
||||
, subscriptions
|
||||
, update
|
||||
, view
|
||||
)
|
||||
|
||||
import Browser exposing (Document)
|
||||
import Browser.Navigation as Nav
|
||||
import Components
|
||||
import Generated.Route as Route exposing (Route)
|
||||
import Task
|
||||
import Url exposing (Url)
|
||||
|
||||
|
||||
|
||||
-- INIT
|
||||
|
||||
|
||||
type alias Flags =
|
||||
()
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ flags : Flags
|
||||
, url : Url
|
||||
, key : Nav.Key
|
||||
}
|
||||
|
||||
|
||||
init : Flags -> Url -> Nav.Key -> ( Model, Cmd Msg )
|
||||
init flags url key =
|
||||
( Model
|
||||
flags
|
||||
url
|
||||
key
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- UPDATE
|
||||
|
||||
|
||||
type Msg
|
||||
= Navigate Route
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
Navigate route ->
|
||||
( model
|
||||
, Nav.pushUrl model.key (Route.toHref route)
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- SUBSCRIPTIONS
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
Sub.none
|
||||
|
||||
|
||||
|
||||
-- VIEW
|
||||
|
||||
|
||||
view :
|
||||
{ page : Document msg
|
||||
, global : Model
|
||||
, toMsg : Msg -> msg
|
||||
}
|
||||
-> Document msg
|
||||
view { page, global, toMsg } =
|
||||
Components.layout
|
||||
{ page = page
|
||||
}
|
||||
|
||||
|
||||
|
||||
-- COMMANDS
|
||||
|
||||
|
||||
send : msg -> Cmd msg
|
||||
send =
|
||||
Task.succeed >> Task.perform identity
|
||||
|
||||
|
||||
navigate : Route -> Cmd Msg
|
||||
navigate route =
|
||||
send (Navigate route)
|
@ -1,141 +0,0 @@
|
||||
module Main exposing (main)
|
||||
|
||||
import Browser exposing (Document)
|
||||
import Browser.Navigation as Nav exposing (Key)
|
||||
import Generated.Pages as Pages
|
||||
import Generated.Route as Route exposing (Route)
|
||||
import Global
|
||||
import Html
|
||||
import Url exposing (Url)
|
||||
|
||||
|
||||
main : Program Flags Model Msg
|
||||
main =
|
||||
Browser.application
|
||||
{ init = init
|
||||
, view = view
|
||||
, update = update
|
||||
, subscriptions = subscriptions
|
||||
, onUrlRequest = LinkClicked
|
||||
, onUrlChange = UrlChanged
|
||||
}
|
||||
|
||||
|
||||
|
||||
-- INIT
|
||||
|
||||
|
||||
type alias Flags =
|
||||
()
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ key : Key
|
||||
, url : Url
|
||||
, global : Global.Model
|
||||
, page : Pages.Model
|
||||
}
|
||||
|
||||
|
||||
init : Flags -> Url -> Key -> ( Model, Cmd Msg )
|
||||
init flags url key =
|
||||
let
|
||||
( global, globalCmd ) =
|
||||
Global.init flags url key
|
||||
|
||||
( page, pageCmd, pageGlobalCmd ) =
|
||||
Pages.init (fromUrl url) global
|
||||
in
|
||||
( Model key url global page
|
||||
, Cmd.batch
|
||||
[ Cmd.map Global globalCmd
|
||||
, Cmd.map Global pageGlobalCmd
|
||||
, Cmd.map Page pageCmd
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
type Msg
|
||||
= LinkClicked Browser.UrlRequest
|
||||
| UrlChanged Url
|
||||
| Global Global.Msg
|
||||
| Page Pages.Msg
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
LinkClicked (Browser.Internal url) ->
|
||||
( model, Nav.pushUrl model.key (Url.toString url) )
|
||||
|
||||
LinkClicked (Browser.External href) ->
|
||||
( model, Nav.load href )
|
||||
|
||||
UrlChanged url ->
|
||||
let
|
||||
( page, pageCmd, globalCmd ) =
|
||||
Pages.init (fromUrl url) model.global
|
||||
in
|
||||
( { model | url = url, page = page }
|
||||
, Cmd.batch
|
||||
[ Cmd.map Page pageCmd
|
||||
, Cmd.map Global globalCmd
|
||||
]
|
||||
)
|
||||
|
||||
Global globalMsg ->
|
||||
let
|
||||
( global, globalCmd ) =
|
||||
Global.update globalMsg model.global
|
||||
in
|
||||
( { model | global = global }
|
||||
, Cmd.map Global globalCmd
|
||||
)
|
||||
|
||||
Page pageMsg ->
|
||||
let
|
||||
( page, pageCmd, globalCmd ) =
|
||||
Pages.update pageMsg model.page model.global
|
||||
in
|
||||
( { model | page = page }
|
||||
, Cmd.batch
|
||||
[ Cmd.map Page pageCmd
|
||||
, Cmd.map Global globalCmd
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
Sub.batch
|
||||
[ model.global
|
||||
|> Global.subscriptions
|
||||
|> Sub.map Global
|
||||
, model.page
|
||||
|> (\page -> Pages.subscriptions page model.global)
|
||||
|> Sub.map Page
|
||||
]
|
||||
|
||||
|
||||
view : Model -> Browser.Document Msg
|
||||
view model =
|
||||
let
|
||||
documentMap :
|
||||
(msg1 -> msg2)
|
||||
-> Document msg1
|
||||
-> Document msg2
|
||||
documentMap fn doc =
|
||||
{ title = doc.title
|
||||
, body = List.map (Html.map fn) doc.body
|
||||
}
|
||||
in
|
||||
Global.view
|
||||
{ page = Pages.view model.page model.global |> documentMap Page
|
||||
, global = model.global
|
||||
, toMsg = Global
|
||||
}
|
||||
|
||||
|
||||
fromUrl : Url -> Route
|
||||
fromUrl =
|
||||
Route.fromUrl >> Maybe.withDefault Route.NotFound
|
@ -1,81 +0,0 @@
|
||||
module Page exposing
|
||||
( Page, Document, Bundle
|
||||
, upgrade
|
||||
, static, sandbox, element, component
|
||||
)
|
||||
|
||||
{-|
|
||||
|
||||
@docs Page, Document, Bundle
|
||||
|
||||
@docs upgrade
|
||||
|
||||
@docs static, sandbox, element, component
|
||||
|
||||
-}
|
||||
|
||||
import Browser
|
||||
import Global
|
||||
import Spa
|
||||
|
||||
|
||||
type alias Document msg =
|
||||
Browser.Document msg
|
||||
|
||||
|
||||
type alias Page flags model msg =
|
||||
Spa.Page flags model msg Global.Model Global.Msg
|
||||
|
||||
|
||||
type alias Bundle msg =
|
||||
Spa.Bundle msg
|
||||
|
||||
|
||||
upgrade :
|
||||
(pageModel -> model)
|
||||
-> (pageMsg -> msg)
|
||||
-> Page pageFlags pageModel pageMsg
|
||||
->
|
||||
{ init : pageFlags -> Global.Model -> ( model, Cmd msg, Cmd Global.Msg )
|
||||
, update : pageMsg -> pageModel -> Global.Model -> ( model, Cmd msg, Cmd Global.Msg )
|
||||
, bundle : pageModel -> Global.Model -> Bundle msg
|
||||
}
|
||||
upgrade =
|
||||
Spa.upgrade
|
||||
|
||||
|
||||
static : { view : Document msg } -> Page flags () msg
|
||||
static =
|
||||
Spa.static
|
||||
|
||||
|
||||
sandbox :
|
||||
{ init : model
|
||||
, update : msg -> model -> model
|
||||
, view : model -> Document msg
|
||||
}
|
||||
-> Page flags model msg
|
||||
sandbox =
|
||||
Spa.sandbox
|
||||
|
||||
|
||||
element :
|
||||
{ init : flags -> ( model, Cmd msg )
|
||||
, update : msg -> model -> ( model, Cmd msg )
|
||||
, subscriptions : model -> Sub msg
|
||||
, view : model -> Document msg
|
||||
}
|
||||
-> Page flags model msg
|
||||
element =
|
||||
Spa.element
|
||||
|
||||
|
||||
component :
|
||||
{ init : Global.Model -> flags -> ( model, Cmd msg, Cmd Global.Msg )
|
||||
, update : Global.Model -> msg -> model -> ( model, Cmd msg, Cmd Global.Msg )
|
||||
, subscriptions : Global.Model -> model -> Sub msg
|
||||
, view : Global.Model -> model -> Document msg
|
||||
}
|
||||
-> Page flags model msg
|
||||
component =
|
||||
Spa.component
|
@ -1,30 +0,0 @@
|
||||
module Pages.Docs exposing (Flags, Model, Msg, page)
|
||||
|
||||
import Html
|
||||
import Page exposing (Document, Page)
|
||||
|
||||
|
||||
type alias Flags =
|
||||
()
|
||||
|
||||
|
||||
type alias Model =
|
||||
()
|
||||
|
||||
|
||||
type alias Msg =
|
||||
Never
|
||||
|
||||
|
||||
page : Page Flags Model Msg
|
||||
page =
|
||||
Page.static
|
||||
{ view = view
|
||||
}
|
||||
|
||||
|
||||
view : Document Msg
|
||||
view =
|
||||
{ title = "Docs"
|
||||
, body = [ Html.text "Docs" ]
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
module Pages.NotFound exposing (Flags, Model, Msg, page)
|
||||
|
||||
import Html
|
||||
import Page exposing (Document, Page)
|
||||
|
||||
|
||||
type alias Flags =
|
||||
()
|
||||
|
||||
|
||||
type alias Model =
|
||||
()
|
||||
|
||||
|
||||
type alias Msg =
|
||||
Never
|
||||
|
||||
|
||||
page : Page Flags Model Msg
|
||||
page =
|
||||
Page.static
|
||||
{ view = view
|
||||
}
|
||||
|
||||
|
||||
view : Document Msg
|
||||
view =
|
||||
{ title = "NotFound"
|
||||
, body = [ Html.text "NotFound" ]
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
module Pages.Top exposing (Flags, Model, Msg, page)
|
||||
|
||||
import Html
|
||||
import Page exposing (Document, Page)
|
||||
|
||||
|
||||
type alias Flags =
|
||||
()
|
||||
|
||||
|
||||
type alias Model =
|
||||
()
|
||||
|
||||
|
||||
type alias Msg =
|
||||
Never
|
||||
|
||||
|
||||
page : Page Flags Model Msg
|
||||
page =
|
||||
Page.static
|
||||
{ view = view
|
||||
}
|
||||
|
||||
|
||||
view : Document Msg
|
||||
view =
|
||||
{ title = "Top"
|
||||
, body = [ Html.text "Top" ]
|
||||
}
|
@ -1,63 +0,0 @@
|
||||
module Add.Component exposing (create)
|
||||
|
||||
import Path exposing (Path)
|
||||
|
||||
|
||||
create : Path -> String
|
||||
create path =
|
||||
"""
|
||||
module Pages.{{name}} exposing (Flags, Model, Msg, page)
|
||||
|
||||
import Global
|
||||
import Html
|
||||
import Page exposing (Document, Page)
|
||||
|
||||
|
||||
type alias Flags =
|
||||
{{flags}}
|
||||
|
||||
|
||||
type alias Model =
|
||||
{}
|
||||
|
||||
|
||||
type Msg
|
||||
= NoOp
|
||||
|
||||
|
||||
page : Page Flags Model Msg
|
||||
page =
|
||||
Page.component
|
||||
{ init = init
|
||||
, update = update
|
||||
, subscriptions = subscriptions
|
||||
, view = view
|
||||
}
|
||||
|
||||
|
||||
init : Global.Model -> Flags -> ( Model, Cmd Msg, Cmd Global.Msg )
|
||||
init global flags =
|
||||
( {}, Cmd.none, Cmd.none )
|
||||
|
||||
|
||||
update : Global.Model -> Msg -> Model -> ( Model, Cmd Msg, Cmd Global.Msg )
|
||||
update global msg model =
|
||||
case msg of
|
||||
NoOp ->
|
||||
( model, Cmd.none, Cmd.none )
|
||||
|
||||
|
||||
subscriptions : Global.Model -> Model -> Sub Msg
|
||||
subscriptions global model =
|
||||
Sub.none
|
||||
|
||||
|
||||
view : Global.Model -> Model -> Document Msg
|
||||
view global model =
|
||||
{ title = "{{name}}"
|
||||
, body = [ Html.text "{{name}}" ]
|
||||
}
|
||||
"""
|
||||
|> String.replace "{{name}}" (Path.toModulePath path)
|
||||
|> String.replace "{{flags}}" (Path.toFlags path)
|
||||
|> String.trim
|
@ -1,42 +0,0 @@
|
||||
module Add.Static exposing (create)
|
||||
|
||||
import Path exposing (Path)
|
||||
|
||||
|
||||
create : Path -> String
|
||||
create path =
|
||||
"""
|
||||
module Pages.{{name}} exposing (Flags, Model, Msg, page)
|
||||
|
||||
import Html
|
||||
import Page exposing (Document, Page)
|
||||
|
||||
|
||||
type alias Flags =
|
||||
{{flags}}
|
||||
|
||||
|
||||
type alias Model =
|
||||
()
|
||||
|
||||
|
||||
type alias Msg =
|
||||
Never
|
||||
|
||||
|
||||
page : Page Flags Model Msg
|
||||
page =
|
||||
Page.static
|
||||
{ view = view
|
||||
}
|
||||
|
||||
|
||||
view : Document Msg
|
||||
view =
|
||||
{ title = "{{name}}"
|
||||
, body = [ Html.text "{{name}}" ]
|
||||
}
|
||||
"""
|
||||
|> String.replace "{{name}}" (Path.toModulePath path)
|
||||
|> String.replace "{{flags}}" (Path.toFlags path)
|
||||
|> String.trim
|
39
elm.json
39
elm.json
@ -1,19 +1,28 @@
|
||||
{
|
||||
"type": "package",
|
||||
"name": "ryannhg/elm-spa",
|
||||
"summary": "a way to build single page apps with Elm",
|
||||
"license": "BSD-3-Clause",
|
||||
"version": "4.1.0",
|
||||
"exposed-modules": [
|
||||
"Spa",
|
||||
"Spa.Advanced"
|
||||
"type": "application",
|
||||
"source-directories": [
|
||||
"src"
|
||||
],
|
||||
"elm-version": "0.19.0 <= v < 0.20.0",
|
||||
"elm-version": "0.19.1",
|
||||
"dependencies": {
|
||||
"elm/browser": "1.0.0 <= v < 2.0.0",
|
||||
"elm/core": "1.0.0 <= v < 2.0.0",
|
||||
"elm/html": "1.0.0 <= v < 2.0.0",
|
||||
"elm/url": "1.0.0 <= v < 2.0.0"
|
||||
"direct": {
|
||||
"elm/browser": "1.0.2",
|
||||
"elm/core": "1.0.5",
|
||||
"elm/html": "1.0.0"
|
||||
},
|
||||
"indirect": {
|
||||
"elm/json": "1.1.3",
|
||||
"elm/time": "1.0.0",
|
||||
"elm/url": "1.0.0",
|
||||
"elm/virtual-dom": "1.0.2"
|
||||
}
|
||||
},
|
||||
"test-dependencies": {}
|
||||
}
|
||||
"test-dependencies": {
|
||||
"direct": {
|
||||
"elm-explorations/test": "1.2.2"
|
||||
},
|
||||
"indirect": {
|
||||
"elm/random": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +0,0 @@
|
||||
# examples
|
||||
|
||||
a few example projects using elm-spa.
|
||||
|
||||
1. [examples/html](./html)
|
||||
1. [examples/elm-ui](./elm-ui)
|
||||
1. [examples/transitions](./transitions)
|
8
examples/elm-spa-dev/.gitignore
vendored
Normal file
8
examples/elm-spa-dev/.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
# Folders to ignore
|
||||
elm-stuff
|
||||
node_modules
|
||||
public/dist
|
||||
src/Spa/Generated
|
||||
|
||||
# MacOS weird stuff
|
||||
.DS_Store
|
41
examples/elm-spa-dev/README.md
Normal file
41
examples/elm-spa-dev/README.md
Normal file
@ -0,0 +1,41 @@
|
||||
# new elm-spa project
|
||||
> More documentation at https://elm-spa.dev
|
||||
|
||||
## local development
|
||||
|
||||
You can get this site up and running with one command:
|
||||
|
||||
```
|
||||
npm start
|
||||
```
|
||||
|
||||
### other commands to know
|
||||
|
||||
There are a handful of commands in the [package.json](./package.json).
|
||||
|
||||
Command | Description
|
||||
:-- | :--
|
||||
`npm run dev` | Run a dev server and automatically build changes.
|
||||
`npm run test:watch` | Run tests as you code.
|
||||
`npm run build` | Build the site for production.
|
||||
`npm run test` | Run the test suite once, great for CI
|
||||
|
||||
|
||||
## deploying
|
||||
|
||||
After you run `npm run build`, the contents of the `public` folder can be hosted as a static site. If you haven't hosted a static site before, I'd recommend using [Netlify](https://netlify.com) (it's free!)
|
||||
|
||||
### using netlify
|
||||
|
||||
Add a `netlify.toml` file next to this README, for standard SPA routing:
|
||||
|
||||
```toml
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
||||
```
|
||||
|
||||
__Build command:__ `npm run build`
|
||||
|
||||
__Publish directory:__ `public`
|
@ -9,17 +9,26 @@
|
||||
"elm/browser": "1.0.2",
|
||||
"elm/core": "1.0.5",
|
||||
"elm/html": "1.0.0",
|
||||
"elm/http": "2.0.0",
|
||||
"elm/json": "1.1.3",
|
||||
"elm/url": "1.0.0",
|
||||
"ryannhg/elm-spa": "4.1.1"
|
||||
"elm-explorations/markdown": "1.0.0"
|
||||
},
|
||||
"indirect": {
|
||||
"elm/json": "1.1.3",
|
||||
"elm/time": "1.0.0",
|
||||
"elm/virtual-dom": "1.0.2"
|
||||
}
|
||||
},
|
||||
"test-dependencies": {
|
||||
"direct": {},
|
||||
"indirect": {}
|
||||
"direct": {
|
||||
"avh4/elm-program-test": "3.2.0",
|
||||
"elm-explorations/test": "1.2.2"
|
||||
},
|
||||
"indirect": {
|
||||
"avh4/elm-fifo": "1.0.4",
|
||||
"elm/bytes": "1.0.8",
|
||||
"elm/file": "1.0.5",
|
||||
"elm/random": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
10
examples/elm-spa-dev/netlify.toml
Normal file
10
examples/elm-spa-dev/netlify.toml
Normal file
@ -0,0 +1,10 @@
|
||||
[[redirects]]
|
||||
from = "/content/*"
|
||||
to = "/content/:splat"
|
||||
status = 200
|
||||
force = true
|
||||
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
2311
examples/elm-spa-dev/package-lock.json
generated
Normal file
2311
examples/elm-spa-dev/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
examples/elm-spa-dev/package.json
Normal file
29
examples/elm-spa-dev/package.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "our-elm-spa-app",
|
||||
"version": "1.0.0",
|
||||
"description": "A project created with elm-spa",
|
||||
"scripts": {
|
||||
"start": "npm install && npm run build:dev && npm run dev",
|
||||
"test": "elm-test",
|
||||
"test:watch": "elm-test --watch",
|
||||
"build": "run-s build:elm-spa build:elm",
|
||||
"build:dev": "run-s build:elm-spa build:dev:elm",
|
||||
"dev": "run-p dev:elm-spa dev:elm",
|
||||
"build:elm": "elm make src/Main.elm --optimize --output=public/dist/elm.compiled.js",
|
||||
"build:dev:elm": "elm make src/Main.elm --debug --output=public/dist/elm.compiled.js || true",
|
||||
"build:elm-spa": "elm-spa build .",
|
||||
"dev:elm": "elm-live src/Main.elm -u -d public -- --debug --output=public/dist/elm.compiled.js",
|
||||
"dev:elm-spa": "chokidar src/Pages -c \"elm-spa build .\""
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"chokidar-cli": "2.1.0",
|
||||
"elm": "0.19.1-3",
|
||||
"elm-live": "4.0.2",
|
||||
"elm-spa": "next",
|
||||
"elm-test": "0.19.1-revision2",
|
||||
"npm-run-all": "4.1.5"
|
||||
}
|
||||
}
|
21
examples/elm-spa-dev/public/content/guide.md
Normal file
21
examples/elm-spa-dev/public/content/guide.md
Normal file
@ -0,0 +1,21 @@
|
||||
# Guide
|
||||
|
||||
Welcome to the __elm-spa__ guide! This guide assumes you are familiar with Elm and have checked out [the official guide](https://guide.elm-lang.org).
|
||||
|
||||
If you have any questions after reading this, please join us the `#elm-spa-users` channel in the [Elm Slack](https://elmlang.herokuapp.com/). There are also amazing folks in the `#beginners` channel that are happy to help with any Elm questions you may have.
|
||||
|
||||
## what is elm-spa?
|
||||
|
||||
Inspired by projects like [Nuxt.js](https://nuxtjs.org/), __elm-spa__ is:
|
||||
|
||||
1. A common __framework__ you can use across projects.
|
||||
1. A __CLI__ that eliminates boilerplate and routing code.
|
||||
1. This __website__, with guides on building SPAs in Elm.
|
||||
|
||||
If you've ever wondered how to call a REST API, handle user authentication, create components, or organize your application- __elm-spa__ is here to help.
|
||||
|
||||
Throughout this guide, [blue links](https://elm-lang.org) will take you to external resources from other sites, while [green links](/guide/getting-started) will take you to other pages on this site!
|
||||
|
||||
---
|
||||
|
||||
Ready to [get started](/guide/getting-started)?
|
77
examples/elm-spa-dev/public/content/guide/authentication.md
Normal file
77
examples/elm-spa-dev/public/content/guide/authentication.md
Normal file
@ -0,0 +1,77 @@
|
||||
# Authentication
|
||||
|
||||
User authentication can be handled in many ways! In this example, we'll define custom pages that:
|
||||
|
||||
1. Are only visible when a user is logged in
|
||||
1. Redirect to a sign in page otherwise.
|
||||
1. Still benefit from elm-spa's route generation!
|
||||
|
||||
Let's assume we have a `user : Maybe User` field in the `Shared.Model`, and have a sign-in page at `SignIn.elm`!
|
||||
|
||||
## Creating Custom Pages
|
||||
|
||||
Because `Spa/Page.elm` is in _your_ project, you can use it to define your own custom page functions.
|
||||
|
||||
As long as those functions return the same `Page` type, they are valid! The only downside is that they won't be available for `elm-spa add` command.
|
||||
|
||||
If your app involves user authentication, you could make a `protectedSandbox` page type that always gets a `User`, and redirects if one is missing:
|
||||
|
||||
```elm
|
||||
-- within Spa/Page.elm
|
||||
|
||||
protectedSandbox :
|
||||
{ init : User -> Url params -> model
|
||||
, update : Msg -> model -> model
|
||||
, view : model -> Document msg
|
||||
}
|
||||
-> Page params (Maybe model) msg
|
||||
protectedSandbox options =
|
||||
{ init =
|
||||
\shared url ->
|
||||
case shared.user of
|
||||
Just user ->
|
||||
options.init user url |> Tuple.mapFirst Just
|
||||
|
||||
Nothing ->
|
||||
( Nothing
|
||||
, Nav.pushUrl url.key (Route.toString Route.SignIn)
|
||||
)
|
||||
, update = -- ... conditionally call options.update
|
||||
, view = -- ... conditionally call options.view
|
||||
, subscriptions = \_ -> Sub.none
|
||||
, save = \_ shared -> shared
|
||||
, load = \_ model -> ( model, Cmd.none )
|
||||
}
|
||||
```
|
||||
|
||||
As long as you return a `Page` type, your page will work with the rest of elm-spa's automated routing!
|
||||
|
||||
### Usage
|
||||
|
||||
```elm
|
||||
-- (within an actual page)
|
||||
|
||||
type alias Model = Maybe SafeModel
|
||||
|
||||
page : Page Params Model Msg
|
||||
page =
|
||||
Page.protectedSandbox
|
||||
{ init = init
|
||||
, update = update
|
||||
, view = view
|
||||
}
|
||||
```
|
||||
|
||||
```elm
|
||||
init : User -> Url Params -> SafeModel
|
||||
update : Msg -> SafeModel -> SafeModel
|
||||
view : SafeModel -> Document Msg
|
||||
```
|
||||
|
||||
One caveat is that the `Model` type exposed by your page is used by the generated code, so your actual model will need a different name (like `SafeModel`).
|
||||
|
||||
But now you know that these functions will only be called if the `User` is really logged in!
|
||||
|
||||
---
|
||||
|
||||
That's it! Swing by the [`#elm-spa-users`](https://elmlang.herokuapp.com/) channel and say hello!
|
59
examples/elm-spa-dev/public/content/guide/beyond-html.md
Normal file
59
examples/elm-spa-dev/public/content/guide/beyond-html.md
Normal file
@ -0,0 +1,59 @@
|
||||
# Beyond HTML
|
||||
|
||||
If you're not an CSS ninja, you may have experienced a bad time styling things on the web. Luckily, there's a __wonderful__ project in the Elm community called [Elm UI](https://package.elm-lang.org/packages/mdgriffith/elm-ui/latest/) that makes it possible to create UIs without any HTML or CSS at all!
|
||||
|
||||
When you create a page with `elm-spa init`, you can choose between the 3 popular options for building Elm applications:
|
||||
|
||||
- `html` - uses [elm/html](https://package.elm-lang.org/packages/elm/html/latest)
|
||||
- `elm-ui` - uses [mdgriffith/elm-ui](https://package.elm-lang.org/packages/mdgriffith/elm-ui/latest)
|
||||
- `elm-css` - uses [rtfeldman/elm-css](https://package.elm-lang.org/packages/rtfeldman/elm-css/latest)
|
||||
|
||||
```terminal
|
||||
elm-spa init my-project --template=elm-ui
|
||||
```
|
||||
|
||||
The `template` option scaffolds out the same starter project, except two files have been modified:
|
||||
|
||||
1. `elm.json` has the `mdgriffith/elm-ui` package installed.
|
||||
2. `Spa/Document.elm` uses `Element` instead of `Html`.
|
||||
|
||||
## Using Something Custom
|
||||
|
||||
Need something other than the three built-in options? Maybe your company is making a custom design system, and you don't want pages to return `Html` or `Element`, you would rather return a `Ui` type instead.
|
||||
|
||||
You can update `src/Spa/Document.elm` with your own custom view library, and the rest of the elm-spa features will still work.
|
||||
|
||||
Here's an example with a made up `Ui` library:
|
||||
|
||||
```elm
|
||||
module Spa.Document exposing (Document, map, toBrowserDocument)
|
||||
|
||||
import Browser
|
||||
import Ui exposing (Ui)
|
||||
|
||||
|
||||
type alias Document msg =
|
||||
{ title : String
|
||||
, body : List (Ui msg)
|
||||
}
|
||||
|
||||
|
||||
map : (msg1 -> msg2) -> Document msg1 -> Document msg2
|
||||
map fn doc =
|
||||
{ title = doc.title
|
||||
, body = List.map (Ui.map fn) doc.body
|
||||
}
|
||||
|
||||
|
||||
toBrowserDocument : Document msg -> Browser.Document msg
|
||||
toBrowserDocument doc =
|
||||
{ title = doc.title
|
||||
, body = List.map Ui.toHtml doc.body
|
||||
}
|
||||
```
|
||||
|
||||
As long as your library can implement those three exposed functions, you're all set. Your pages can all use your awesome view package!
|
||||
|
||||
---
|
||||
|
||||
Next up, we'll take a look at [Authentication](/guide/authentication)
|
192
examples/elm-spa-dev/public/content/guide/components.md
Normal file
192
examples/elm-spa-dev/public/content/guide/components.md
Normal file
@ -0,0 +1,192 @@
|
||||
# Components
|
||||
|
||||
In Elm, components don't have to be complicated! In fact, most of the time, you can use boring functions:
|
||||
|
||||
```elm
|
||||
module Components.Footer exposing (view)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (class)
|
||||
|
||||
|
||||
view : Html msg
|
||||
view =
|
||||
footer [ class "footer" ] [ text "built with elm-spa" ]
|
||||
```
|
||||
|
||||
## Passing in data
|
||||
|
||||
If you have data you need to display in a component, you can pass them in as arguments:
|
||||
|
||||
```elm
|
||||
module Components.Navbar exposing (view)
|
||||
|
||||
import Api.User exposing (User)
|
||||
import Html exposing (..)
|
||||
import Html.Attributes as Attr exposing (class)
|
||||
import Spa.Generated.Route as Route
|
||||
|
||||
|
||||
view : { user : Maybe User } -> Html msg
|
||||
view options =
|
||||
header [ class "navbar" ]
|
||||
[ a [ href Route.Top ] [ text "Home" ]
|
||||
, a [ href Route.NotFound ] [ text "Not found" ]
|
||||
, case options.user of
|
||||
Just user -> button [] [ text "Sign out" ]
|
||||
Nothing -> button [] [ text "Sign in" ]
|
||||
]
|
||||
|
||||
|
||||
href : Route -> Html.Attribute msg
|
||||
href route =
|
||||
Attr.href (Route.toString route)
|
||||
```
|
||||
|
||||
## Handling messages
|
||||
|
||||
What's the easiest way to make a component reusable? Pass in the messages it sends! Rather than giving it it's own hardcoded `Msg` type, pass in the `msg` as an argument.
|
||||
|
||||
This enables the caller to decide how to handle events from components, and makes it easier to test component functions without needing to mock the entire application.
|
||||
|
||||
```elm
|
||||
import Html.Events as Events
|
||||
|
||||
view :
|
||||
{ user : Maybe User
|
||||
, onSignIn : msg
|
||||
, onSignOut : msg
|
||||
}
|
||||
-> Html msg
|
||||
view options =
|
||||
header [ class "navbar" ]
|
||||
[ a [ href Route.Top ] [ text "Home" ]
|
||||
, a [ href Route.NotFound ] [ text "Not found" ]
|
||||
, case options.user of
|
||||
Just _ ->
|
||||
button [ Events.onClick options.onSignOut ]
|
||||
[ text "Sign out" ]
|
||||
Nothing ->
|
||||
button [ Events.onClick options.onSignIn ]
|
||||
[ text "Sign in" ]
|
||||
]
|
||||
```
|
||||
|
||||
## Fancy Components
|
||||
|
||||
In JavaScript frameworks like React or Vue.js, it's common to have a component track its own data, view, and handle updates to that view. In Elm, we _could_ follow that methodology with `Model/Msg` and `init/update/view`, but it's not ideal.
|
||||
|
||||
Unlike in JS, our `view` functions can only return one type of `msg`. This means using `Html.map` and `Cmd.map` every time you want to use a component. That can become a mess when you begin nesting components!
|
||||
|
||||
Modules should be [built around data structures](https://www.youtube.com/watch?v=XpDsk374LDE), and it's easier to reuse functions rather than nesting `update` functions:
|
||||
|
||||
### Making a Carousel Component
|
||||
|
||||
```elm
|
||||
module Components.Carousel exposing
|
||||
( Carousel
|
||||
, create
|
||||
, next, previous, select
|
||||
, view
|
||||
)
|
||||
|
||||
type Carousel slide
|
||||
|
||||
create : slide -> List slide -> Carousel slide
|
||||
|
||||
next : Carousel slide -> Carousel slide
|
||||
previous : Carousel slide -> Carousel slide
|
||||
select : Int -> Carousel slide -> Carousel slide
|
||||
|
||||
view :
|
||||
{ carousel : Carousel slide
|
||||
, onNext : msg
|
||||
, onPrevious : msg
|
||||
, onSelect : Int -> msg
|
||||
, viewSlide : slide -> Html msg
|
||||
}
|
||||
-> Html msg
|
||||
```
|
||||
|
||||
The above example shows a file that provides:
|
||||
|
||||
1. A new data structure– `Carousel`
|
||||
1. Functions to update that structure:
|
||||
`next`, `previous`, and `select`
|
||||
1. The way to `view` that structure
|
||||
|
||||
The implementation for `Carousel` isn't exposed, so callers won't break if you change it later. If you'd like to see the full Carousel implementation, [here it is](https://gist.github.com/ryannhg/b26c0d6a5d2bfd74643e7da6543c5170).
|
||||
|
||||
### Using a Carousel Component
|
||||
|
||||
Here's how you might call it in a page:
|
||||
|
||||
```elm
|
||||
import Components.Carousel as Carousel exposing (Carousel)
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ testimonials : Carousel Testimonial
|
||||
}
|
||||
|
||||
type alias Testimonial =
|
||||
{ quote : String
|
||||
, author : String
|
||||
}
|
||||
|
||||
init : Model
|
||||
init =
|
||||
{ testimonials =
|
||||
Carousel.create
|
||||
{ quote = "Cats have ears", author = "Ryan" }
|
||||
[ { quote = "Dogs also have ears", author = "Alexa" }
|
||||
, { quote = "I have ears", author = "Erik" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```elm
|
||||
type Msg
|
||||
= NextTestimonial
|
||||
| PreviousTestimonial
|
||||
| SelectTestimonial Int
|
||||
|
||||
update : Msg -> Model -> Model
|
||||
update msg model =
|
||||
case msg of
|
||||
NextTestimonial ->
|
||||
{ model | testimonials = Carousel.next model.testimonials }
|
||||
|
||||
PreviousTestimonial ->
|
||||
{ model | testimonials = Carousel.previous model.testimonials }
|
||||
|
||||
SelectTestimonial index ->
|
||||
{ model | testimonials = Carousel.select index model.testimonials }
|
||||
```
|
||||
|
||||
```elm
|
||||
view : Model -> Html Msg
|
||||
view model =
|
||||
div [ class "page" ]
|
||||
[ Carousel.view
|
||||
{ carousel = model.testimonials
|
||||
, onNext = NextTestimonial
|
||||
, onPrevious = PreviousTestimonial
|
||||
, onSelect = SelectTestimonial
|
||||
, viewSlide = viewTestimonial
|
||||
}
|
||||
]
|
||||
|
||||
viewTestimonial : Testimonial -> Html msg
|
||||
viewTestimonial options =
|
||||
div [ class "testimonial" ]
|
||||
[ p [ class "quote" ] [ text options.quote ]
|
||||
, p [ class "author" ] [ text options.author ]
|
||||
]
|
||||
```
|
||||
|
||||
Just like before, we pass our `msg` types into the component, rather than give them their own special `Msg` types. Let your page handle those updates and your code will be much easier to read.
|
||||
|
||||
---
|
||||
|
||||
Next, let's talk about [using APIs](/guide/using-apis)
|
87
examples/elm-spa-dev/public/content/guide/getting-started.md
Normal file
87
examples/elm-spa-dev/public/content/guide/getting-started.md
Normal file
@ -0,0 +1,87 @@
|
||||
# Getting Started
|
||||
|
||||
Getting started with __elm-spa__ is easy! Make sure you have the latest stable version of [NodeJS](https://nodejs.org/en/) installed on your system. At the time of writing, that's version `12.18.2`.
|
||||
|
||||
```terminal
|
||||
npx elm-spa init
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
This one-time command will create a new project in a folder called `our-elm-spa`. Here's an overview of that folder:
|
||||
|
||||
```
|
||||
elm.json
|
||||
package.json
|
||||
|
||||
public/
|
||||
├─ index.html
|
||||
├─ main.js
|
||||
└─ style.css
|
||||
|
||||
src/
|
||||
├─ Pages/
|
||||
| ├─ Top.elm
|
||||
| └─ NotFound.elm
|
||||
├─ Spa/
|
||||
| ├─ Document.elm
|
||||
| ├─ Page.elm
|
||||
| └─ Url.elm
|
||||
├─ Main.elm
|
||||
└─ Shared.elm
|
||||
|
||||
tests/
|
||||
└─ README.md
|
||||
```
|
||||
|
||||
### The project folder
|
||||
|
||||
There are a few interesting things in the project folder:
|
||||
|
||||
File | Description
|
||||
:-- | :--
|
||||
`elm.json` | Defines all of our Elm project dependencies.
|
||||
`package.json` | Has `build`, `dev`, and `test` scripts so anyone with [NodeJS](https://nodejs.org) installed can easily run our project.
|
||||
`src/` | Where our frontend Elm application lives.
|
||||
`tests/` | Where our Elm tests live.
|
||||
`public/` | A static directory for serving HTML, JS, CSS, images, and more!
|
||||
|
||||
### The `src` Folder
|
||||
|
||||
The `src` folder will contain all your Elm code:
|
||||
|
||||
File | Description
|
||||
:-- | :--
|
||||
`Pages/Top.elm` | The homepage for our single page application.
|
||||
`Pages/NotFound.elm` | The page to show if we're at an invalid route.
|
||||
`Spa/Document.elm` | The kind of thing each page's `view` returns (changing this allows support for [elm-ui](https://github.com/mdgriffith/elm-ui) or [elm-css](https://github.com/rtfeldman/elm-css))
|
||||
`Spa/Page.elm` | Defines the four page types (`static`, `sandbox`, `element`, and `application`)
|
||||
`Spa/Url.elm` | Defines a type that holds route parameters, query parameters (automatically passed into the `init` function of `element` and `application` pages)
|
||||
`Main.elm` | The entrypoint to the app, that wires everything together.
|
||||
`Shared.elm` | The place to define layouts and shared data between pages.
|
||||
|
||||
### The `public` folder
|
||||
|
||||
The public folder is served statically. Use this folder to serve images, CSS, JS, and other static assets.
|
||||
|
||||
File | Description
|
||||
:-- | :--
|
||||
`index.html` | The HTML loaded by the server.
|
||||
`main.js` | The JS that starts our Elm single page application.
|
||||
`style.css` | A place to add in some CSS styles.
|
||||
|
||||
#### Using assets
|
||||
|
||||
Here are examples of how to access files in the public folder via URL:
|
||||
|
||||
File Location | URL
|
||||
:-- | :---
|
||||
`public/main.js` | `/main.js`
|
||||
`public/style.css` | `/style.css`
|
||||
`public/images/puppy.png` | `/images/puppy.png`
|
||||
|
||||
__Include the starting slash in your URL!__ If it's missing, it will look for your assets relative to the current URL, which means some pages will work and others won't. (`main.js` vs `/main.js`)
|
||||
|
||||
---
|
||||
|
||||
Next up is [Installation](/guide/installation), which will introduce the CLI.
|
75
examples/elm-spa-dev/public/content/guide/installation.md
Normal file
75
examples/elm-spa-dev/public/content/guide/installation.md
Normal file
@ -0,0 +1,75 @@
|
||||
# Installation
|
||||
|
||||
You can install `elm-spa` via [npm](https://nodejs.org/):
|
||||
|
||||
```terminal
|
||||
npm install -g elm-spa@5.0.0
|
||||
```
|
||||
|
||||
Now, you can run `elm-spa` from the terminal!
|
||||
|
||||
## Hello, CLI
|
||||
|
||||
If you're ever stuck- run `elm-spa help`, the CLI comes with __built-in documentation__!
|
||||
|
||||
```terminal
|
||||
elm-spa help
|
||||
|
||||
elm-spa – version 5.0.0
|
||||
|
||||
elm-spa init – create a new project
|
||||
elm-spa add – add a new page
|
||||
elm-spa build – generate routes and pages automatically
|
||||
|
||||
elm-spa version – print version number
|
||||
```
|
||||
|
||||
## elm-spa init
|
||||
|
||||
The `init` command scaffolds a new __elm-spa__ project.
|
||||
|
||||
```terminal
|
||||
elm-spa init
|
||||
```
|
||||
|
||||
When you run the command, you will be presented with an interactive dialogue to choose between:
|
||||
|
||||
1. The UI Library ([elm-ui](https://package.elm-lang.org/packages/mdgriffith/elm-ui/latest), [elm-css](https://package.elm-lang.org/packages/rtfeldman/elm-css/latest), or [html](https://package.elm-lang.org/packages/elm/html/latest))
|
||||
2. The folder name
|
||||
|
||||
Each project works and behaves the same way, but `elm.json`, `Spa.Document`, and the `Shared.view` are updated to use the UI library of your choice.
|
||||
|
||||
## elm-spa add
|
||||
|
||||
You can add more pages to an existing __elm-spa__ project with the `elm-spa add` command.
|
||||
|
||||
```terminal
|
||||
elm-spa add
|
||||
```
|
||||
|
||||
Just like the last command, an interactive dialogue will ask you two things:
|
||||
|
||||
1. The type of page (static, sandbox, element, or application)
|
||||
1. The page's module name
|
||||
|
||||
The meaning of each of the page types will be explained in the [Pages](/guide/pages) section!
|
||||
|
||||
__Note:__ Running the `elm-spa add` command will overwrite the contents of the existing file, so don't use it for upgrading an existing page.
|
||||
|
||||
## elm-spa build
|
||||
|
||||
This command does the automatic code generation for you. If you follow the naming conventions outlined in the next section, this is where elm-spa saves you time!
|
||||
|
||||
```terminal
|
||||
elm-spa build
|
||||
```
|
||||
|
||||
The generated code is in the `src/Spa/Generated` folder! Feel free to take a look, it's human readable Elm code!
|
||||
|
||||
__No need to call this!__ The project created by `elm-spa init` actually calls this under the hood.
|
||||
|
||||
Just use `npm start`, and you're good!
|
||||
|
||||
---
|
||||
|
||||
Next, let's talk about the [Routing](/guide/routing)!
|
99
examples/elm-spa-dev/public/content/guide/pages.md
Normal file
99
examples/elm-spa-dev/public/content/guide/pages.md
Normal file
@ -0,0 +1,99 @@
|
||||
# Pages
|
||||
|
||||
By default, there are four kinds of pages you can create with __elm-spa__. Always choose the simplest one for the job!
|
||||
|
||||
## Static
|
||||
|
||||
A simple, static page that just returns a view.
|
||||
|
||||
```elm
|
||||
page : Page Params Model Msg
|
||||
page =
|
||||
Page.static
|
||||
{ view = view
|
||||
}
|
||||
```
|
||||
|
||||
```elm
|
||||
view : Url Params -> Document Msg
|
||||
```
|
||||
|
||||
## Sandbox
|
||||
|
||||
A page that needs to maintain local state.
|
||||
|
||||
```elm
|
||||
page : Page Params Model Msg
|
||||
page =
|
||||
Page.sandbox
|
||||
{ init = init
|
||||
, update = update
|
||||
, view = view
|
||||
}
|
||||
```
|
||||
|
||||
```elm
|
||||
init : Url Params -> Model
|
||||
update : Msg -> Model -> Model
|
||||
view : Model -> Document Msg
|
||||
```
|
||||
|
||||
## Element
|
||||
|
||||
A page that can make side effects with `Cmd` and listen for updates as `Sub`.
|
||||
|
||||
```elm
|
||||
page : Page Params Model Msg
|
||||
page =
|
||||
Page.element
|
||||
{ init = init
|
||||
, update = update
|
||||
, view = view
|
||||
, subscriptions = subscriptions
|
||||
}
|
||||
```
|
||||
|
||||
```elm
|
||||
init : Url Params -> ( Model, Cmd Msg )
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
view : Model -> Document Msg
|
||||
subscriptions : Model -> Sub Msg
|
||||
```
|
||||
|
||||
## Application
|
||||
|
||||
A page that can read and write to the shared model.
|
||||
|
||||
```elm
|
||||
page : Page Params Model Msg
|
||||
page =
|
||||
Page.application
|
||||
{ init = init
|
||||
, update = update
|
||||
, view = view
|
||||
, subscriptions = subscriptions
|
||||
, save = save
|
||||
, load = load
|
||||
}
|
||||
```
|
||||
|
||||
```elm
|
||||
init : Shared.Model -> Url Params -> ( Model, Cmd Msg )
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
view : Model -> Document Msg
|
||||
subscriptions : Model -> Sub Msg
|
||||
save : Model -> Shared.Model -> Shared.Model
|
||||
load : Shared.Model -> Model -> ( Model, Cmd Msg )
|
||||
```
|
||||
|
||||
Because `save` and `load` are new constructs, here's an explanation of how they work:
|
||||
|
||||
#### __save__
|
||||
Anytime you update the `Model` with `init` or `update`, `save` is automatically called (by `Main.elm`). This allows you to persist local state to the entire application.
|
||||
|
||||
#### __load__
|
||||
Much like `update`, the `load` function gets called whenever the `Shared.Model` changes. This allows you to respond to external changes to update your local state or send a command!
|
||||
|
||||
---
|
||||
|
||||
Let's take a deeper look at [Shared](/guide/shared) together.
|
117
examples/elm-spa-dev/public/content/guide/routing.md
Normal file
117
examples/elm-spa-dev/public/content/guide/routing.md
Normal file
@ -0,0 +1,117 @@
|
||||
# Routing
|
||||
|
||||
With __elm-spa__, the names of pages in the `src/Pages` folder automatically generate your routes! Check out the following examples to learn more.
|
||||
|
||||
## Static Routes
|
||||
|
||||
You can create a static route like `/contact` or `/not-found` by creating an elm file in `src/Pages`:
|
||||
|
||||
File | URL
|
||||
:-- | :--
|
||||
`People.elm` | `/people`
|
||||
`About/Careers.elm` | `/about/careers`
|
||||
`OurTeam.elm` | `/our-team`
|
||||
|
||||
__Capitalization matters!__ Notice how `OurTeam` became `our-team`? Capital letters within file names are translated to dashes in URLs.
|
||||
|
||||
## Top Level Routes
|
||||
|
||||
Routes like the homepage use the reserved `Top` keyword to indicate that a page should not be a static route.
|
||||
|
||||
File | URL
|
||||
:-- | :--
|
||||
`Top.elm` | `/`
|
||||
`Example/Top.elm` | `/example`
|
||||
`Top/Top.elm` | `/top`
|
||||
|
||||
__Reserved, but possible!__ If you actually need a `/top` route, you can still make one by using `Top.elm` within a `Top` folder. (As shown above!)
|
||||
|
||||
## Dynamic Routes
|
||||
|
||||
Sometimes it's nice to have one page that works for slightly different URLs. __elm-spa__ uses this convention in file names to indicate a dynamic route:
|
||||
|
||||
__`Authors/Name_String.elm`__
|
||||
|
||||
URL | Params
|
||||
:-- | :--
|
||||
`/authors/ryan` | `{ name = "ryan" }`
|
||||
`/authors/alexa` | `{ name = "alexa" }`
|
||||
|
||||
__`Posts/Id_Int.elm`__
|
||||
|
||||
URL | Params
|
||||
:-- | :--
|
||||
`/posts/123` | `{ id = 123 }`
|
||||
`/posts/456` | `{ id = 456 }`
|
||||
|
||||
You can access these dynamic parameters from the `Url Params` value passed into each page type!
|
||||
|
||||
__Supported Parameters__: Only `String` and `Int` dynamic parameters are supported.
|
||||
|
||||
### Nested Dynamic Routes
|
||||
|
||||
You can also nest your dynamic routes. Here's an example:
|
||||
|
||||
|
||||
__`Users/User_String/Posts/Post_Id.elm`__
|
||||
|
||||
URL | Params
|
||||
:-- | :--
|
||||
`/users/ryan/posts/123` | `{ user = "ryan"`<br/>`, id = 123`<br/>`}`
|
||||
`/users/alexa/posts/456` | `{ user = "alexa"`<br/>`, id = 456`<br/>`}`
|
||||
|
||||
## URL Params
|
||||
|
||||
As we'll see in the next section, every page will get access to `Url Params`– these allow you access a few things:
|
||||
|
||||
```elm
|
||||
type alias Url params =
|
||||
{ params : params
|
||||
, query : Dict String String
|
||||
, key : Browser.Navigation.Key
|
||||
, rawUrl : Url.Url
|
||||
}
|
||||
```
|
||||
|
||||
#### params
|
||||
|
||||
Each dynamic page has it's own params, pulled from the URL. There are examples in the "Params" column above.
|
||||
|
||||
```elm
|
||||
type alias Params =
|
||||
{ name : String
|
||||
}
|
||||
|
||||
view : Url Params -> Document Msg
|
||||
view url =
|
||||
{ title = "Author: " ++ url.params.name
|
||||
, body = -- ...
|
||||
}
|
||||
```
|
||||
|
||||
#### query
|
||||
|
||||
A dictionary of query parameters. Here are some examples:
|
||||
|
||||
```elm
|
||||
-- https://elm-spa.dev
|
||||
Dict.get "name" url.query == Nothing
|
||||
|
||||
-- https://elm-spa.dev?name=ryan
|
||||
Dict.get "name" url.query == Just "ryan"
|
||||
|
||||
-- https://elm-spa.dev?name
|
||||
Dict.get "name" url.query == Just ""
|
||||
```
|
||||
|
||||
#### key
|
||||
|
||||
Required for programmatic navigation with `Nav.pushUrl` and other functions from [elm/browser](https://package.elm-lang.org/packages/elm/browser/latest/Browser-Navigation#pushUrl)
|
||||
|
||||
#### rawUrl
|
||||
|
||||
The original URL in case you need any other information like the protocol, port, etc.
|
||||
|
||||
---
|
||||
|
||||
Let's take a closer look at [Pages](/guide/pages)!
|
142
examples/elm-spa-dev/public/content/guide/shared.md
Normal file
142
examples/elm-spa-dev/public/content/guide/shared.md
Normal file
@ -0,0 +1,142 @@
|
||||
# Shared
|
||||
|
||||
Whether your sharing layouts or information between pages, the `Shared` module is the place to be!
|
||||
|
||||
## Flags
|
||||
|
||||
If you have initial data you hope to pass into your Elm application, you should provide it via `Flags`,
|
||||
|
||||
When you create a project with `elm-spa init`, a file will be created at `public/main.js`:
|
||||
|
||||
```javascript
|
||||
// (in public/main.js)
|
||||
var flags = null
|
||||
var app = Elm.Main.init({ flags: flags })
|
||||
```
|
||||
|
||||
The value passed into the `flags` needs to match up with the type of `Shared.Flags`, for it to be passed into `Shared.init`.
|
||||
|
||||
Here's an example:
|
||||
|
||||
```javascript
|
||||
// (in public/main.js)
|
||||
var flags = { project: "elm-spa", year: 2020 }
|
||||
```
|
||||
|
||||
```elm
|
||||
-- (in src/Shared.elm)
|
||||
type alias Flags =
|
||||
{ project : String
|
||||
, year : Int
|
||||
}
|
||||
```
|
||||
|
||||
Once you get comfortable with flags, I recommend always using `Json.Value` from the [elm/json](https://package.elm-lang.org/packages/elm/json/latest) package as your Flags:
|
||||
|
||||
```elm
|
||||
import Json.Decode as Json
|
||||
|
||||
type alias Flags =
|
||||
Json.Value
|
||||
|
||||
type alias InitialData =
|
||||
{ project : String
|
||||
, year : Int
|
||||
}
|
||||
|
||||
decoder : Json.Decoder InitialData
|
||||
decoder =
|
||||
Json.map2 InitialData
|
||||
(Json.field "project" Json.string)
|
||||
(Json.field "year" Json.int)
|
||||
|
||||
init : Flags -> Url -> Key -> ( Model, Cmd Msg )
|
||||
init flags url key =
|
||||
case Json.decodeValue decoder flags of
|
||||
Ok initialData -> -- Initialize app
|
||||
Err reason -> -- Handle failure
|
||||
```
|
||||
|
||||
This way, you can create a decoder to gracefully handle the JSON being sent into your Elm application.
|
||||
|
||||
Learn more about [Flags](https://guide.elm-lang.org/interop/flags.html) in the official Elm guide.
|
||||
|
||||
## Model
|
||||
|
||||
All data in `Shared.Model` will persist across page navigation.
|
||||
|
||||
By default, it only contains `key` and `url`, which are required for the programmatic navigation and reading URL information in your application.
|
||||
|
||||
This makes it a great choice for things like logged-in users, dark-mode, or any other data displayed on shared components needed by navbars or footers.
|
||||
|
||||
```elm
|
||||
type alias Model =
|
||||
{ key : Key
|
||||
, url : Url
|
||||
, user : Maybe User
|
||||
}
|
||||
```
|
||||
|
||||
Here we added a `user` field that we can update with the next function!
|
||||
|
||||
## update
|
||||
|
||||
The `Shared.update` function is just like a normal `update` function in Elm. It takes in messages and returns the latest version of the `Model`. In this case, the `Model` is the `Shared.Model` mentioned above.
|
||||
|
||||
```elm
|
||||
type Msg
|
||||
= SignIn User
|
||||
| SignOut
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
SignIn user ->
|
||||
( { model | user = Just user }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
SignOut ->
|
||||
( { model | user = Nothing }
|
||||
, Cmd.none
|
||||
)
|
||||
```
|
||||
|
||||
This is just an example of using `update` with the `user` field we added earlier. Let's call those messages from our view.
|
||||
|
||||
## view
|
||||
|
||||
The `Shared.view` function is a great place to render things that should persist across page transitions. It comes with more than just a `Model`, so you can insert the `page` whereever you'd like:
|
||||
|
||||
```elm
|
||||
import Components.Navbar as Navbar
|
||||
import Components.Footer as Footer
|
||||
|
||||
|
||||
view :
|
||||
{ page : Document msg
|
||||
, toMsg : Msg -> msg
|
||||
}
|
||||
-> Model
|
||||
-> Document msg
|
||||
view { page, toMsg } model =
|
||||
{ title = page.title
|
||||
, body =
|
||||
[ Navbar.view
|
||||
{ user = model.user
|
||||
, onSignIn = toMsg SignIn
|
||||
, onSignOut = toMsg SignOut
|
||||
}
|
||||
, div [ class "page" ] page.body
|
||||
, Footer.view
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Using the `toMsg` function passed in the first argument, we're able to convert `Shared.Msg` to the same `msg` that our `view` function returns.
|
||||
|
||||
If you want components to send `Shared.Msg`, make sure to use that function first!
|
||||
|
||||
---
|
||||
|
||||
Let's take a look at [Components](/guide/components) now!
|
182
examples/elm-spa-dev/public/content/guide/using-apis.md
Normal file
182
examples/elm-spa-dev/public/content/guide/using-apis.md
Normal file
@ -0,0 +1,182 @@
|
||||
# Using APIs
|
||||
|
||||
Most applications interact with a REST API or a GraphQL endpoint to access data.
|
||||
For this guide, we'll be using the [Reddit REST API](https://www.reddit.com/dev/api/#GET_hot) to fetch the latest posts from [r/elm](https://www.reddit.com/r/elm).
|
||||
## Defining a module
|
||||
|
||||
Just like before, we'll define modules based on data structures:
|
||||
|
||||
```elm
|
||||
module Api.Reddit.Listing exposing
|
||||
( Listing
|
||||
, hot, new, top
|
||||
)
|
||||
```
|
||||
|
||||
## Storing the data
|
||||
|
||||
In Elm, there's a better way to model API data other than just toggling a `loading` boolean from `true` to `false`. Using [the RemoteData pattern](https://www.youtube.com/watch?v=NLcRzOyrH08), we can represent all states data from the web might be in, and display the right thing to our users:
|
||||
|
||||
```elm
|
||||
module Api exposing (Data(..), expectJson)
|
||||
|
||||
type Data value
|
||||
= NotAsked
|
||||
| Loading
|
||||
| Failure Http.Error
|
||||
| Success value
|
||||
|
||||
expectJson : (Data value -> msg) -> Decoder value -> Expect msg
|
||||
```
|
||||
|
||||
The `expectJson` function is a replacement for [Http.expectJson](https://package.elm-lang.org/packages/elm/http/latest/Http#expectJson) which uses `Result` instead.
|
||||
|
||||
## Working with JSON
|
||||
|
||||
The [elm/json](https://package.elm-lang.org/packages/elm/json/latest) package allows us to handle JSON from APIs, without crashing our application if the JSON isn't what we initially expected. We do that by creating decoders:
|
||||
|
||||
```elm
|
||||
import Json.Decode as Json
|
||||
|
||||
type alias Listing =
|
||||
{ title : String
|
||||
, author : String
|
||||
, url : String
|
||||
}
|
||||
|
||||
decoder : Json.Decoder Listing
|
||||
decoder =
|
||||
Json.map3 Listing
|
||||
(Json.field "title" Json.string)
|
||||
(Json.field "author_fullname" Json.string)
|
||||
(Json.field "url" Json.string)
|
||||
```
|
||||
|
||||
## Actually fetching listings
|
||||
|
||||
Let's combine our new `Api` and `decoder` to actually fetch those Reddit posts! We'll use the [elm/http](https://package.elm-lang.org/packages/elm/http/latest) to make the GET request.
|
||||
|
||||
```elm
|
||||
hot : { onResponse : Api.Data (List Listing) -> msg } -> Cmd msg
|
||||
hot options =
|
||||
Http.get
|
||||
{ url = "https://api.reddit.com/r/elm/hot"
|
||||
, expect =
|
||||
Api.expectJson options.onResponse
|
||||
(Json.at [ "data", "children" ] (Json.list decoder))
|
||||
}
|
||||
```
|
||||
|
||||
The actual listings are located inside `data.children`, so we used `Json.at` and `Json.list` to before we use our `decoder`.
|
||||
|
||||
```javascript
|
||||
{ "data": { "children": [ ... ] } }
|
||||
```
|
||||
|
||||
We can reuse that code to implement `new` and `top`. Let's move the reusable bits into `listings`, and just pass in the endpoint as a string.
|
||||
|
||||
```elm
|
||||
-- API ENDPOINTS
|
||||
|
||||
hot : { onResponse : Api.Data (List Listing) -> msg } -> Cmd msg
|
||||
hot =
|
||||
listings "hot"
|
||||
|
||||
|
||||
new : { onResponse : Api.Data (List Listing) -> msg } -> Cmd msg
|
||||
new =
|
||||
listings "new"
|
||||
|
||||
|
||||
top : { onResponse : Api.Data (List Listing) -> msg } -> Cmd msg
|
||||
top =
|
||||
listings "top"
|
||||
|
||||
|
||||
listings :
|
||||
String
|
||||
-> { onResponse : Api.Data (List Listing) -> msg }
|
||||
-> Cmd msg
|
||||
listings endpoint options =
|
||||
Http.get
|
||||
{ url = "https://api.reddit.com/r/elm/" ++ endpoint
|
||||
, expect =
|
||||
Api.expectJson options.onResponse
|
||||
(Json.at [ "data", "children" ] (Json.list decoder))
|
||||
}
|
||||
```
|
||||
|
||||
## Calling the API
|
||||
|
||||
Now that we have our new `Api.Reddit.Listing` module, we can use it in our pages. Here's an example of what that looks like:
|
||||
|
||||
```elm
|
||||
import Api
|
||||
import Api.Reddit.Listing exposing (Listing)
|
||||
|
||||
type alias Model =
|
||||
{ listings : Api.Data (List Listing)
|
||||
}
|
||||
|
||||
init : Url Params -> ( Model, Cmd Msg )
|
||||
init url =
|
||||
( Model Api.Loading
|
||||
, Api.Reddit.Listing.hot
|
||||
{ onResponse = GotHotListings
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
This sends an initial request to fetch the top Reddit posts from r/elm. We need to handle the response in our update function.
|
||||
|
||||
```elm
|
||||
type Msg
|
||||
= GotHotListings (Api.Data (List Listing))
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
GotHotListings data ->
|
||||
( { model | listings = data }
|
||||
, Cmd.none
|
||||
)
|
||||
```
|
||||
|
||||
Notice how we stored the entire `Api.Data` response, whether it succeeded or failed? That's perfect for the next bit, where we have control over how to show the user the state of the listings:
|
||||
|
||||
```elm
|
||||
view : Model -> Document Msg
|
||||
view model =
|
||||
{ title = "Posts"
|
||||
, body =
|
||||
[ div [ class "page" ]
|
||||
[ viewListings model.listings
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
viewListings : Api.Data (List Listing) -> Html msg
|
||||
viewListings data =
|
||||
case data of
|
||||
Api.NotAsked -> text "Not asked"
|
||||
Api.Loading -> text "Loading..."
|
||||
Api.Failure _ -> text "Something went wrong..."
|
||||
Api.Success listings ->
|
||||
div [ class "listings" ]
|
||||
(List.map viewListing listings)
|
||||
|
||||
viewListing : Listing -> Html msg
|
||||
viewListing listing =
|
||||
div [ class "listing" ]
|
||||
[ a [ class "title", href listing.url ]
|
||||
[ text listing.title ]
|
||||
, p [ class "author" ]
|
||||
[ text ("Author: " ++ listing.author) ]
|
||||
]
|
||||
```
|
||||
|
||||
That's it! Here are the [actual files](https://gist.github.com/ryannhg/3ce83ec17ed473717e5604c7047e4d2c) used for this section.
|
||||
|
||||
---
|
||||
|
||||
Next we'll go [Beyond HTML](/guide/beyond-html), to explore other view options.
|
BIN
examples/elm-spa-dev/public/favicon.png
Normal file
BIN
examples/elm-spa-dev/public/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.0 KiB |
BIN
examples/elm-spa-dev/public/images/logo.png
Normal file
BIN
examples/elm-spa-dev/public/images/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 54 KiB |
3
examples/elm-spa-dev/public/images/logo.svg
Normal file
3
examples/elm-spa-dev/public/images/logo.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 14 KiB |
21
examples/elm-spa-dev/public/index.html
Normal file
21
examples/elm-spa-dev/public/index.html
Normal file
@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>elm-spa</title>
|
||||
<meta name="description" content="single page apps made easy.">
|
||||
<link rel="shortcut icon" href="/favicon.png" type="image/x-png">
|
||||
<!-- CSS goes here -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.1.1/styles/atom-one-dark.min.css" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Baloo+Da+2:wght@800&family=Fira+Code:wght@400;700&family=Nunito:ital,wght@0,400;0,800;1,400&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- JavaScript goes here -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.1.1/highlight.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.1.1/languages/elm.min.js"></script>
|
||||
<script src="/dist/elm.compiled.js"></script>
|
||||
<script src="/main.js"></script>
|
||||
</body>
|
||||
</html>
|
8
examples/elm-spa-dev/public/main.js
Normal file
8
examples/elm-spa-dev/public/main.js
Normal file
@ -0,0 +1,8 @@
|
||||
// Initial data to pass in to Elm (linked with `Shared.Flags`)
|
||||
// https://guide.elm-lang.org/interop/flags.html
|
||||
var flags = null
|
||||
|
||||
// Start our Elm application
|
||||
var app = Elm.Main.init({ flags: flags })
|
||||
|
||||
// Ports go here: https://guide.elm-lang.org/interop/ports.html
|
4
examples/elm-spa-dev/public/robots.txt
Normal file
4
examples/elm-spa-dev/public/robots.txt
Normal file
@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://www.elm-spa.dev/dist/sitemap.xml
|
100
examples/elm-spa-dev/public/style.css
Normal file
100
examples/elm-spa-dev/public/style.css
Normal file
@ -0,0 +1,100 @@
|
||||
@import "https://nope.rhg.dev/dist/1.0.0/core.min.css";
|
||||
|
||||
html, body {
|
||||
color: #222;
|
||||
background-color: #f8f0f4;
|
||||
font-family: 'Nunito', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
body { overflow-y: scroll; }
|
||||
|
||||
table { line-height: 1.2; }
|
||||
|
||||
.font-h5, .font-h6, .font-body, .text-body {
|
||||
font-weight: normal;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.font-h1, .font-h2, .font-h3, .font-h4, .text-header {
|
||||
font-family: 'Baloo Da 2', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.05em;
|
||||
line-height: 0.9;
|
||||
}
|
||||
|
||||
.font-h1 { font-size: 6rem; }
|
||||
.font-h5 { font-size: 1.5rem; }
|
||||
|
||||
.text--bigger { font-size: 1.2rem; }
|
||||
|
||||
/* links, buttons, and hoverable elements */
|
||||
.link { text-decoration: underline; font-weight: bold; color: #409844; }
|
||||
|
||||
.hoverable, .link, .button {
|
||||
cursor: pointer;
|
||||
transition: opacity 200ms ease-in-out;
|
||||
}
|
||||
|
||||
.hoverable:hover:not([disabled]),
|
||||
.link:hover:not([disabled]),
|
||||
.button:hover:not([disabled]) {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.link.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.size--120 { min-width: 6rem; min-height: 6rem; max-width: 6rem; max-height: 6rem; }
|
||||
.width--sidebar { min-width: 12rem; max-width: 12rem; }
|
||||
|
||||
.color--faint { color: rgba(0, 0, 0, 0.6); }
|
||||
.color--green { color: #409844; }
|
||||
|
||||
.fadeable { transition: transform 200ms ease-in-out; }
|
||||
.no-width { width: 0; overflow: hidden; }
|
||||
.invisible { opacity: 0; visibility: hidden; }
|
||||
|
||||
.readable { max-width: 36em; }
|
||||
|
||||
.sticky { position: sticky; top: 0; }
|
||||
|
||||
code.lang-terminal { position: relative; padding-left: 1.2rem }
|
||||
code.lang-terminal::before { position: absolute; top: 0.0675em; left: 0; content: "$"; margin-left: 0; opacity: 0.5; pointer-events: none; }
|
||||
|
||||
/* markdown styles */
|
||||
.markdown h1 { font-weight: bold; font-size: 4rem; letter-spacing: -0.05em; font-family: 'Baloo Da 2', sans-serif; }
|
||||
.markdown h2 { font-weight: bold; font-size: 2.5rem; margin-top: 1rem; letter-spacing: -0.05em; }
|
||||
.markdown h3 { font-weight: bold; font-size: 2rem; margin-top: 1rem; letter-spacing: -0.05em; }
|
||||
.markdown h4 { font-weight: bold; font-size: 1.5rem; margin-top: 0.5rem; letter-spacing: -0.05em; }
|
||||
.markdown h5 { font-weight: bold; font-size: 1.25rem; }
|
||||
.markdown h6 { font-weight: bold; font-size: 1rem; }
|
||||
.markdown p, .markdown ul, .markdown ol { color: #444; line-height: 1.4; font-size: 1.25rem; letter-spacing: -0.01em; }
|
||||
.markdown blockquote { line-height: 1.4; opacity: 0.5; padding: 0.25rem 1rem; border-left: solid 0.125rem #888; }
|
||||
.markdown pre { line-height: 1.3; padding: 1rem; background: #333; color: white; border-radius: 0.125em; }
|
||||
.markdown code { font-family: 'Fira Code', monospace; font-size: 0.9em; letter-spacing: 0; }
|
||||
.markdown p code, .markdown ul code, .markdown ol code { border: solid 1px #ccc; padding: 0 0.25em; border-radius: 0.125em; white-space: nowrap; }
|
||||
.markdown a { text-decoration: underline; font-weight: bold; color: #409844; }
|
||||
.markdown a[href^=https] { text-decoration: underline; font-weight: bold; color: dodgerblue; }
|
||||
.markdown table { border-radius: 0.125em; border: solid 1px #ccc; }
|
||||
.markdown td { vertical-align: top }
|
||||
.markdown td, .markdown th { padding: 0.5rem; }
|
||||
.markdown tbody tr:nth-child(2n+1) { background: #f2e3eb; }
|
||||
.markdown hr { height: 2px; width: 12rem; max-width: 100%; background-color: #ccc; border: 0; margin-left: 0; margin-right: 0; }
|
||||
|
||||
/* responsive font scaling */
|
||||
html { font-size: 16px; }
|
||||
@media screen and (min-width: 641px) { html { font-size: 18px; } }
|
||||
@media screen and (min-width: 1600px) { html { font-size: 20px; } }
|
||||
|
||||
/* accessible code highlighting */
|
||||
.hljs-comment, .hljs-quote { color: #959ba7; }
|
||||
|
||||
.home-pre {
|
||||
background: linear-gradient(#333, #222);
|
||||
color: white;
|
||||
padding: 0.5em 1em;
|
||||
border-radius: 25px;
|
||||
box-shadow: 0 0.5em 1em rgba(0,0,0,0.25);
|
||||
}
|
24
examples/elm-spa-dev/sitemap.js
Normal file
24
examples/elm-spa-dev/sitemap.js
Normal file
@ -0,0 +1,24 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const filesInGuideFolder =
|
||||
fs.readdirSync(path.join(__dirname, 'public/content/guide'))
|
||||
|
||||
const routes =
|
||||
[ '/',
|
||||
'/guide',
|
||||
'/examples',
|
||||
...(
|
||||
filesInGuideFolder
|
||||
.map(file => '/guide/' + file.split('.md')[0])
|
||||
)
|
||||
]
|
||||
|
||||
Promise.resolve(routes)
|
||||
.then(routes => routes.map(route =>`<url><loc>https://www.elm-spa.dev${route}</loc></url>`))
|
||||
.then(entries => `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${entries.join('\n ')}
|
||||
</urlset>`)
|
||||
.then(content => fs.writeFileSync(path.join(__dirname, 'public', 'dist', 'sitemap.xml'), content, { encoding: 'utf-8' }))
|
||||
.then(console.log)
|
||||
.catch(console.error)
|
54
examples/elm-spa-dev/src/Api/Data.elm
Normal file
54
examples/elm-spa-dev/src/Api/Data.elm
Normal file
@ -0,0 +1,54 @@
|
||||
module Api.Data exposing (Data(..), fromHttpResult, view)
|
||||
|
||||
import Html exposing (Html)
|
||||
import Html.Attributes as Attr exposing (class, classList)
|
||||
import Spa.Transition
|
||||
|
||||
|
||||
type Data value
|
||||
= Loading
|
||||
| Success value
|
||||
| Failure String
|
||||
|
||||
|
||||
fromHttpResult : Result error value -> Data value
|
||||
fromHttpResult result =
|
||||
case result of
|
||||
Ok value ->
|
||||
Success value
|
||||
|
||||
Err _ ->
|
||||
Failure "Either this is a broken link or there's missing documentation!"
|
||||
|
||||
|
||||
view : (value -> Html msg) -> Data value -> Html msg
|
||||
view toHtml data =
|
||||
Html.div
|
||||
[ classList [ ( "invisible", data == Loading ) ]
|
||||
, Attr.style "transition" Spa.Transition.properties.page
|
||||
]
|
||||
<|
|
||||
case data of
|
||||
Loading ->
|
||||
[]
|
||||
|
||||
Success value ->
|
||||
[ toHtml value ]
|
||||
|
||||
Failure reason ->
|
||||
[ Html.div [ class "column spacing-small" ]
|
||||
[ Html.div [ class "column spacing-small" ]
|
||||
[ Html.h1 [ class "font-h2" ] [ Html.text "well. that's weird." ]
|
||||
, Html.p [] [ Html.text reason ]
|
||||
]
|
||||
, Html.p []
|
||||
[ Html.text "Could you please "
|
||||
, Html.a
|
||||
[ class "link"
|
||||
, Attr.href "https://github.com/ryannhg/elm-spa/issues/new?labels=documentation&title=Broken%20docs%20link"
|
||||
, Attr.target "_blank"
|
||||
]
|
||||
[ Html.text "let me know?" ]
|
||||
]
|
||||
]
|
||||
]
|
12
examples/elm-spa-dev/src/Api/Markdown.elm
Normal file
12
examples/elm-spa-dev/src/Api/Markdown.elm
Normal file
@ -0,0 +1,12 @@
|
||||
module Api.Markdown exposing (get)
|
||||
|
||||
import Api.Data exposing (Data)
|
||||
import Http
|
||||
|
||||
|
||||
get : { file : String, onResponse : Data String -> msg } -> Cmd msg
|
||||
get options =
|
||||
Http.get
|
||||
{ url = "/content/" ++ options.file
|
||||
, expect = Http.expectString (Api.Data.fromHttpResult >> options.onResponse)
|
||||
}
|
2
examples/elm-spa-dev/src/Api/README.md
Normal file
2
examples/elm-spa-dev/src/Api/README.md
Normal file
@ -0,0 +1,2 @@
|
||||
# src/Api
|
||||
> Call backend API services
|
16
examples/elm-spa-dev/src/Components/Markdown.elm
Normal file
16
examples/elm-spa-dev/src/Components/Markdown.elm
Normal file
@ -0,0 +1,16 @@
|
||||
module Components.Markdown exposing (view)
|
||||
|
||||
import Html exposing (Html)
|
||||
import Html.Attributes exposing (class)
|
||||
import Markdown
|
||||
|
||||
|
||||
view : String -> Html msg
|
||||
view =
|
||||
Markdown.toHtmlWith
|
||||
{ githubFlavored = Just { tables = True, breaks = False }
|
||||
, defaultHighlighting = Nothing
|
||||
, sanitize = False
|
||||
, smartypants = False
|
||||
}
|
||||
[ class "markdown readable column spacing-small" ]
|
2
examples/elm-spa-dev/src/Components/README.md
Normal file
2
examples/elm-spa-dev/src/Components/README.md
Normal file
@ -0,0 +1,2 @@
|
||||
# src/Components
|
||||
> Reusable views and things
|
78
examples/elm-spa-dev/src/Components/Sidebar.elm
Normal file
78
examples/elm-spa-dev/src/Components/Sidebar.elm
Normal file
@ -0,0 +1,78 @@
|
||||
module Components.Sidebar exposing (view)
|
||||
|
||||
{-|
|
||||
|
||||
@docs Options, view
|
||||
|
||||
-}
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (class, href)
|
||||
import Spa.Generated.Route as Route exposing (Route)
|
||||
import Utils.String
|
||||
|
||||
|
||||
type alias Section =
|
||||
{ title : String
|
||||
, links : List Link
|
||||
}
|
||||
|
||||
|
||||
type alias Link =
|
||||
{ label : String
|
||||
, route : Route
|
||||
}
|
||||
|
||||
|
||||
view : Route -> Html msg
|
||||
view currentRoute =
|
||||
let
|
||||
viewSection : Section -> Html msg
|
||||
viewSection section =
|
||||
Html.section [ class "column spacing-small align-left" ]
|
||||
[ h3 [ class "font-h4" ] [ text section.title ]
|
||||
, div [ class "column spacing-small align-left" ]
|
||||
(List.map viewLink section.links)
|
||||
]
|
||||
|
||||
viewLink : Link -> Html msg
|
||||
viewLink link =
|
||||
a
|
||||
[ href (Route.toString link.route)
|
||||
, if link.route == currentRoute then
|
||||
class "color--green text-underline text-bold"
|
||||
|
||||
else
|
||||
class "text-underline hoverable"
|
||||
]
|
||||
[ text link.label ]
|
||||
in
|
||||
div [ class "hidden-mobile width--sidebar column pt-medium spacing-medium align-left" ]
|
||||
(List.map viewSection sections)
|
||||
|
||||
|
||||
sections : List Section
|
||||
sections =
|
||||
let
|
||||
guide : String -> Link
|
||||
guide label =
|
||||
Link label <|
|
||||
Route.Guide__Topic_String
|
||||
{ topic = Utils.String.sluggify label
|
||||
}
|
||||
in
|
||||
[ { title = "Guide"
|
||||
, links =
|
||||
[ Link "Introduction" Route.Guide
|
||||
, guide "Getting Started"
|
||||
, guide "Installation"
|
||||
, guide "Routing"
|
||||
, guide "Pages"
|
||||
, guide "Shared"
|
||||
, guide "Components"
|
||||
, guide "Using APIs"
|
||||
, guide "Beyond HTML"
|
||||
, guide "Authentication"
|
||||
]
|
||||
}
|
||||
]
|
173
examples/elm-spa-dev/src/Main.elm
Normal file
173
examples/elm-spa-dev/src/Main.elm
Normal file
@ -0,0 +1,173 @@
|
||||
module Main exposing (main)
|
||||
|
||||
import Browser
|
||||
import Browser.Navigation as Nav
|
||||
import Shared exposing (Flags)
|
||||
import Spa.Document as Document exposing (Document)
|
||||
import Spa.Generated.Pages as Pages
|
||||
import Spa.Generated.Route as Route exposing (Route)
|
||||
import Spa.Transition
|
||||
import Url exposing (Url)
|
||||
import Utils.Cmd
|
||||
|
||||
|
||||
main : Program Flags Model Msg
|
||||
main =
|
||||
Browser.application
|
||||
{ init = init
|
||||
, update = update
|
||||
, subscriptions = subscriptions
|
||||
, view = view >> Document.toBrowserDocument
|
||||
, onUrlRequest = LinkClicked
|
||||
, onUrlChange = UrlChanged
|
||||
}
|
||||
|
||||
|
||||
fromUrl : Url -> Route
|
||||
fromUrl =
|
||||
Route.fromUrl >> Maybe.withDefault Route.NotFound
|
||||
|
||||
|
||||
|
||||
-- INIT
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ url : Url
|
||||
, key : Nav.Key
|
||||
, shared : Shared.Model
|
||||
, page : Pages.Model
|
||||
, isTransitioning : { layout : Bool, page : Bool }
|
||||
, nextUrl : Url
|
||||
}
|
||||
|
||||
|
||||
init : Flags -> Url -> Nav.Key -> ( Model, Cmd Msg )
|
||||
init flags url key =
|
||||
let
|
||||
shared =
|
||||
Shared.init flags key
|
||||
|
||||
route =
|
||||
fromUrl url
|
||||
|
||||
( page, pageCmd ) =
|
||||
Pages.init route shared key url
|
||||
in
|
||||
( Model url key shared page { layout = True, page = True } url
|
||||
, Cmd.batch
|
||||
[ Cmd.map Pages pageCmd
|
||||
, Utils.Cmd.delay Spa.Transition.delays.layout (FadeIn url)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- UPDATE
|
||||
|
||||
|
||||
type Msg
|
||||
= LinkClicked Browser.UrlRequest
|
||||
| UrlChanged Url
|
||||
| Shared Shared.Msg
|
||||
| Pages Pages.Msg
|
||||
| FadeIn Url
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
LinkClicked (Browser.Internal url) ->
|
||||
( model
|
||||
, Nav.pushUrl model.key (Url.toString url)
|
||||
)
|
||||
|
||||
LinkClicked (Browser.External href) ->
|
||||
( model
|
||||
, Nav.load href
|
||||
)
|
||||
|
||||
UrlChanged url ->
|
||||
if url == model.url then
|
||||
( model, Cmd.none )
|
||||
|
||||
else if url.path == model.url.path then
|
||||
loadPage url model
|
||||
|
||||
else
|
||||
( { model | isTransitioning = { layout = False, page = True }, nextUrl = url }
|
||||
, Utils.Cmd.delay Spa.Transition.delays.page (FadeIn url)
|
||||
)
|
||||
|
||||
FadeIn url ->
|
||||
loadPage url model
|
||||
|
||||
Shared sharedMsg ->
|
||||
let
|
||||
( shared, cmd ) =
|
||||
Shared.update sharedMsg model.shared
|
||||
|
||||
( page, pageCmd ) =
|
||||
Pages.load model.page shared
|
||||
in
|
||||
( { model | page = page, shared = shared }
|
||||
, Cmd.map Shared cmd
|
||||
)
|
||||
|
||||
Pages pageMsg ->
|
||||
let
|
||||
( page, cmd ) =
|
||||
Pages.update pageMsg model.page
|
||||
|
||||
shared =
|
||||
Pages.save page model.shared
|
||||
in
|
||||
( { model | page = page, shared = shared }
|
||||
, Cmd.map Pages cmd
|
||||
)
|
||||
|
||||
|
||||
loadPage : Url -> Model -> ( Model, Cmd Msg )
|
||||
loadPage url model =
|
||||
let
|
||||
route =
|
||||
fromUrl url
|
||||
|
||||
( page, cmd ) =
|
||||
Pages.init route model.shared model.key url
|
||||
|
||||
shared =
|
||||
Pages.save page model.shared
|
||||
in
|
||||
( { model
|
||||
| url = url
|
||||
, nextUrl = url
|
||||
, page = page
|
||||
, shared = shared
|
||||
, isTransitioning = { layout = False, page = False }
|
||||
}
|
||||
, Cmd.map Pages cmd
|
||||
)
|
||||
|
||||
|
||||
view : Model -> Document Msg
|
||||
view model =
|
||||
Shared.view
|
||||
{ page = Pages.view model.page |> Document.map Pages
|
||||
, shared = model.shared
|
||||
, toMsg = Shared
|
||||
, isTransitioning = model.isTransitioning
|
||||
, route = fromUrl model.url
|
||||
, shouldShowSidebar = isSidebarPage model.url
|
||||
}
|
||||
|
||||
|
||||
isSidebarPage : Url -> Bool
|
||||
isSidebarPage { path } =
|
||||
String.startsWith "/docs" path || String.startsWith "/guide" path
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
Pages.subscriptions model.page
|
||||
|> Sub.map Pages
|
42
examples/elm-spa-dev/src/Pages/Examples.elm
Normal file
42
examples/elm-spa-dev/src/Pages/Examples.elm
Normal file
@ -0,0 +1,42 @@
|
||||
module Pages.Examples exposing (Model, Msg, Params, page)
|
||||
|
||||
import Html exposing (div, h1, p, text)
|
||||
import Html.Attributes exposing (class)
|
||||
import Spa.Document exposing (Document)
|
||||
import Spa.Page as Page exposing (Page)
|
||||
import Spa.Url as Url exposing (Url)
|
||||
|
||||
|
||||
page : Page Params Model Msg
|
||||
page =
|
||||
Page.static
|
||||
{ view = view
|
||||
}
|
||||
|
||||
|
||||
type alias Model =
|
||||
Url Params
|
||||
|
||||
|
||||
type alias Msg =
|
||||
Never
|
||||
|
||||
|
||||
|
||||
-- VIEW
|
||||
|
||||
|
||||
type alias Params =
|
||||
()
|
||||
|
||||
|
||||
view : Url Params -> Document Msg
|
||||
view { params } =
|
||||
{ title = "Examples"
|
||||
, body =
|
||||
[ div [ class "column spacing-tiny py-large center-x text-center" ]
|
||||
[ h1 [ class "font-h1" ] [ text "examples" ]
|
||||
, p [ class "font-h5 color--faint" ] [ text "coming soon!" ]
|
||||
]
|
||||
]
|
||||
}
|
67
examples/elm-spa-dev/src/Pages/Guide.elm
Normal file
67
examples/elm-spa-dev/src/Pages/Guide.elm
Normal file
@ -0,0 +1,67 @@
|
||||
module Pages.Guide exposing (Model, Msg, Params, page)
|
||||
|
||||
import Api.Data exposing (Data)
|
||||
import Api.Markdown
|
||||
import Components.Markdown
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (class)
|
||||
import Spa.Document exposing (Document)
|
||||
import Spa.Generated.Route as Route exposing (Route)
|
||||
import Spa.Page as Page exposing (Page)
|
||||
import Spa.Url exposing (Url)
|
||||
|
||||
|
||||
type alias Params =
|
||||
()
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ route : Route
|
||||
, content : Data String
|
||||
}
|
||||
|
||||
|
||||
page : Page Params Model Msg
|
||||
page =
|
||||
Page.element
|
||||
{ init = init
|
||||
, update = update
|
||||
, subscriptions = always Sub.none
|
||||
, view = view
|
||||
}
|
||||
|
||||
|
||||
init : Url Params -> ( Model, Cmd Msg )
|
||||
init { rawUrl } =
|
||||
( { route = Route.fromUrl rawUrl |> Maybe.withDefault Route.NotFound
|
||||
, content = Api.Data.Loading
|
||||
}
|
||||
, Api.Markdown.get
|
||||
{ file = "guide.md"
|
||||
, onResponse = GotMarkdown
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
type Msg
|
||||
= GotMarkdown (Data String)
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
GotMarkdown content ->
|
||||
( { model | content = content }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
|
||||
view : Model -> Document Msg
|
||||
view model =
|
||||
{ title = "guide | elm-spa"
|
||||
, body =
|
||||
[ Api.Data.view
|
||||
Components.Markdown.view
|
||||
model.content
|
||||
]
|
||||
}
|
77
examples/elm-spa-dev/src/Pages/Guide/Topic_String.elm
Normal file
77
examples/elm-spa-dev/src/Pages/Guide/Topic_String.elm
Normal file
@ -0,0 +1,77 @@
|
||||
module Pages.Guide.Topic_String exposing (Model, Msg, Params, page)
|
||||
|
||||
import Api.Data exposing (Data)
|
||||
import Api.Markdown
|
||||
import Components.Markdown
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (class, href)
|
||||
import Spa.Document exposing (Document)
|
||||
import Spa.Generated.Route as Route exposing (Route)
|
||||
import Spa.Page as Page exposing (Page)
|
||||
import Spa.Url exposing (Url)
|
||||
|
||||
|
||||
type alias Params =
|
||||
{ topic : String
|
||||
}
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ title : String
|
||||
, route : Route
|
||||
, content : Data String
|
||||
}
|
||||
|
||||
|
||||
page : Page Params Model Msg
|
||||
page =
|
||||
Page.element
|
||||
{ init = init
|
||||
, update = update
|
||||
, subscriptions = always Sub.none
|
||||
, view = view
|
||||
}
|
||||
|
||||
|
||||
init : Url Params -> ( Model, Cmd Msg )
|
||||
init { rawUrl, params } =
|
||||
( { route = Route.fromUrl rawUrl |> Maybe.withDefault Route.NotFound
|
||||
, title = params.topic
|
||||
, content = Api.Data.Loading
|
||||
}
|
||||
, Api.Markdown.get
|
||||
{ file = "guide/" ++ params.topic ++ ".md"
|
||||
, onResponse = GotMarkdown
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
type Msg
|
||||
= GotMarkdown (Data String)
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
GotMarkdown content ->
|
||||
( { model | content = content }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
|
||||
view : Model -> Document Msg
|
||||
view model =
|
||||
{ title = prettifySlug model.title ++ " | guide | elm-spa"
|
||||
, body =
|
||||
[ Api.Data.view
|
||||
Components.Markdown.view
|
||||
model.content
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
prettifySlug : String -> String
|
||||
prettifySlug slug =
|
||||
slug
|
||||
|> String.replace "-" " "
|
||||
|> String.replace "elm spa" "elm-spa"
|
43
examples/elm-spa-dev/src/Pages/NotFound.elm
Normal file
43
examples/elm-spa-dev/src/Pages/NotFound.elm
Normal file
@ -0,0 +1,43 @@
|
||||
module Pages.NotFound exposing (Model, Msg, Params, page, view)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (class, href)
|
||||
import Spa.Document exposing (Document)
|
||||
import Spa.Generated.Route as Route
|
||||
import Spa.Page as Page exposing (Page)
|
||||
import Spa.Url exposing (Url)
|
||||
|
||||
|
||||
page : Page Params Model Msg
|
||||
page =
|
||||
Page.static
|
||||
{ view = view
|
||||
}
|
||||
|
||||
|
||||
type alias Params =
|
||||
()
|
||||
|
||||
|
||||
type alias Model =
|
||||
Url Params
|
||||
|
||||
|
||||
type alias Msg =
|
||||
Never
|
||||
|
||||
|
||||
view : Url Params -> Document Msg
|
||||
view _ =
|
||||
{ title = "404"
|
||||
, body =
|
||||
[ div [ class "column spacing-tiny" ]
|
||||
[ h1 [ class "font-h2" ] [ text "Page not found" ]
|
||||
, p [ class "font-body color--faint" ]
|
||||
[ text "How about the "
|
||||
, a [ class "link", href (Route.toString Route.Top) ] [ text "homepage" ]
|
||||
, text "? That's a nice place."
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
2
examples/elm-spa-dev/src/Pages/README.md
Normal file
2
examples/elm-spa-dev/src/Pages/README.md
Normal file
@ -0,0 +1,2 @@
|
||||
# src/Pages
|
||||
> Correspond to a URL route
|
69
examples/elm-spa-dev/src/Pages/Top.elm
Normal file
69
examples/elm-spa-dev/src/Pages/Top.elm
Normal file
@ -0,0 +1,69 @@
|
||||
module Pages.Top exposing (Model, Msg, Params, page, view)
|
||||
|
||||
import Components.Markdown
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (alt, class, src)
|
||||
import Spa.Document exposing (Document)
|
||||
import Spa.Page as Page exposing (Page)
|
||||
import Spa.Url exposing (Url)
|
||||
|
||||
|
||||
page : Page Params Model Msg
|
||||
page =
|
||||
Page.static
|
||||
{ view = view
|
||||
}
|
||||
|
||||
|
||||
type alias Params =
|
||||
()
|
||||
|
||||
|
||||
type alias Model =
|
||||
Url Params
|
||||
|
||||
|
||||
type alias Msg =
|
||||
Never
|
||||
|
||||
|
||||
view : Url Params -> Document Msg
|
||||
view _ =
|
||||
{ title = "elm-spa"
|
||||
, body =
|
||||
[ div [ class "column spacing-medium center-x" ]
|
||||
[ hero
|
||||
, viewSection "No assembly required." """
|
||||
Build reliable [Elm](https://elm-lang.org) applications with the wonderful tools created by the community– brought together in one place:
|
||||
- Use __elm-ui__ to create build UIs without CSS.
|
||||
- Comes with __elm-live__, a hot-reloading web server.
|
||||
- Create a test suite with __elm-test__
|
||||
"""
|
||||
, span [] []
|
||||
, viewSection "Ready to learn more?" """
|
||||
[Checkout the official guide](/guide)
|
||||
"""
|
||||
, span [] []
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
hero : Html msg
|
||||
hero =
|
||||
div [ class "column spacing-medium py-large center-x text-center" ]
|
||||
[ div [ class "column spacing-tiny center-x" ]
|
||||
[ img [ alt "elm-spa logo", class "size--120", src "/images/logo.svg" ] []
|
||||
, h1 [ class "font-h1" ] [ text "elm-spa" ]
|
||||
, p [ class "font-h5 color--faint" ] [ text "single page apps made easy." ]
|
||||
]
|
||||
, pre [ class "home-pre" ] [ code [ class "lang-terminal" ] [ text "npx elm-spa init" ] ]
|
||||
]
|
||||
|
||||
|
||||
viewSection : String -> String -> Html msg
|
||||
viewSection title content =
|
||||
section [ class "column spacing-small center-x" ]
|
||||
[ h3 [ class "font-h2" ] [ text title ]
|
||||
, Components.Markdown.view content
|
||||
]
|
42
examples/elm-spa-dev/src/Pages/Tour.elm
Normal file
42
examples/elm-spa-dev/src/Pages/Tour.elm
Normal file
@ -0,0 +1,42 @@
|
||||
module Pages.Tour exposing (Model, Msg, Params, page)
|
||||
|
||||
import Html exposing (div, h1, p, text)
|
||||
import Html.Attributes exposing (class)
|
||||
import Spa.Document exposing (Document)
|
||||
import Spa.Page as Page exposing (Page)
|
||||
import Spa.Url as Url exposing (Url)
|
||||
|
||||
|
||||
page : Page Params Model Msg
|
||||
page =
|
||||
Page.static
|
||||
{ view = view
|
||||
}
|
||||
|
||||
|
||||
type alias Model =
|
||||
Url Params
|
||||
|
||||
|
||||
type alias Msg =
|
||||
Never
|
||||
|
||||
|
||||
|
||||
-- VIEW
|
||||
|
||||
|
||||
type alias Params =
|
||||
()
|
||||
|
||||
|
||||
view : Url Params -> Document Msg
|
||||
view { params } =
|
||||
{ title = "Tour"
|
||||
, body =
|
||||
[ div [ class "column spacing-tiny py-large center-x text-center" ]
|
||||
[ h1 [ class "font-h1" ] [ text "tour" ]
|
||||
, p [ class "font-h5 color--faint" ] [ text "coming soon!" ]
|
||||
]
|
||||
]
|
||||
}
|
7
examples/elm-spa-dev/src/Ports.elm
Normal file
7
examples/elm-spa-dev/src/Ports.elm
Normal file
@ -0,0 +1,7 @@
|
||||
port module Ports exposing (log)
|
||||
|
||||
-- A place to interact with JavaScript
|
||||
-- https://guide.elm-lang.org/interop/ports.html
|
||||
|
||||
|
||||
port log : String -> Cmd msg
|
126
examples/elm-spa-dev/src/Shared.elm
Normal file
126
examples/elm-spa-dev/src/Shared.elm
Normal file
@ -0,0 +1,126 @@
|
||||
module Shared exposing
|
||||
( Flags
|
||||
, Model
|
||||
, Msg
|
||||
, init
|
||||
, subscriptions
|
||||
, update
|
||||
, view
|
||||
)
|
||||
|
||||
import Browser.Navigation as Nav
|
||||
import Components.Sidebar
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (class, classList, href, style)
|
||||
import Spa.Document exposing (Document)
|
||||
import Spa.Generated.Route as Route exposing (Route)
|
||||
import Spa.Transition
|
||||
|
||||
|
||||
type alias Flags =
|
||||
()
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ key : Nav.Key
|
||||
}
|
||||
|
||||
|
||||
init : Flags -> Nav.Key -> Model
|
||||
init _ key =
|
||||
Model key
|
||||
|
||||
|
||||
type Msg
|
||||
= ReplaceMe
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
ReplaceMe ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
Sub.none
|
||||
|
||||
|
||||
view :
|
||||
{ page : Document msg
|
||||
, shared : Model
|
||||
, toMsg : Msg -> msg
|
||||
, isTransitioning : { layout : Bool, page : Bool }
|
||||
, shouldShowSidebar : Bool
|
||||
, route : Route
|
||||
}
|
||||
-> Document msg
|
||||
view ({ page, isTransitioning } as options) =
|
||||
{ title = page.title
|
||||
, body =
|
||||
[ div
|
||||
[ class "column container px-medium spacing-small fill-y"
|
||||
, style "transition" Spa.Transition.properties.layout
|
||||
, classList [ ( "invisible", isTransitioning.layout ) ]
|
||||
]
|
||||
[ viewNavbar
|
||||
, div [ class "flex row align-top relative" ]
|
||||
[ viewSidebar options
|
||||
, viewPage options
|
||||
]
|
||||
, viewFooter
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
viewNavbar : Html msg
|
||||
viewNavbar =
|
||||
header [ class "py-medium row spacing-small spread center-y" ]
|
||||
[ a [ class "font-h3 text-header hoverable", href "/" ]
|
||||
[ text "elm-spa" ]
|
||||
, div [ class "row spacing-small text--bigger" ]
|
||||
[ a [ class "link", href (Route.toString Route.Guide) ] [ text "guide" ]
|
||||
|
||||
-- , a [ class "link", href (Route.toString Route.Tour) ] [ text "tour" ]
|
||||
, a [ class "link", href (Route.toString Route.Examples) ] [ text "examples" ]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
viewPage :
|
||||
{ options
|
||||
| page : Document msg
|
||||
, isTransitioning : { layout : Bool, page : Bool }
|
||||
}
|
||||
-> Html msg
|
||||
viewPage { page, isTransitioning } =
|
||||
main_
|
||||
[ class "flex"
|
||||
, style "transition" Spa.Transition.properties.page
|
||||
, classList [ ( "invisible", isTransitioning.page ) ]
|
||||
]
|
||||
page.body
|
||||
|
||||
|
||||
viewSidebar : { options | shouldShowSidebar : Bool, route : Route } -> Html msg
|
||||
viewSidebar { shouldShowSidebar, route } =
|
||||
aside
|
||||
[ class "hidden-mobile fadeable sticky"
|
||||
, classList
|
||||
[ ( "invisible", not shouldShowSidebar )
|
||||
, ( "no-width", not shouldShowSidebar )
|
||||
]
|
||||
]
|
||||
[ Components.Sidebar.view route
|
||||
]
|
||||
|
||||
|
||||
viewFooter : Html msg
|
||||
viewFooter =
|
||||
footer [ class "footer pt-large pb-medium text-center color--faint" ]
|
||||
[ text "[ Built with "
|
||||
, a [ class "text-underline hoverable", Html.Attributes.target "_blank", href "https://elm-lang.org" ] [ text "Elm" ]
|
||||
, text " ]"
|
||||
]
|
26
examples/elm-spa-dev/src/Spa/Document.elm
Normal file
26
examples/elm-spa-dev/src/Spa/Document.elm
Normal file
@ -0,0 +1,26 @@
|
||||
module Spa.Document exposing
|
||||
( Document
|
||||
, map
|
||||
, toBrowserDocument
|
||||
)
|
||||
|
||||
import Browser
|
||||
import Html exposing (Html)
|
||||
|
||||
|
||||
type alias Document msg =
|
||||
{ title : String
|
||||
, body : List (Html msg)
|
||||
}
|
||||
|
||||
|
||||
map : (msg1 -> msg2) -> Document msg1 -> Document msg2
|
||||
map fn doc =
|
||||
{ title = doc.title
|
||||
, body = List.map (Html.map fn) doc.body
|
||||
}
|
||||
|
||||
|
||||
toBrowserDocument : Document msg -> Browser.Document msg
|
||||
toBrowserDocument doc =
|
||||
doc
|
126
examples/elm-spa-dev/src/Spa/Page.elm
Normal file
126
examples/elm-spa-dev/src/Spa/Page.elm
Normal file
@ -0,0 +1,126 @@
|
||||
module Spa.Page exposing
|
||||
( Page
|
||||
, static, sandbox, element, application
|
||||
, Upgraded, Bundle, upgrade
|
||||
)
|
||||
|
||||
{-|
|
||||
|
||||
@docs Page
|
||||
@docs static, sandbox, element, application
|
||||
@docs Upgraded, Bundle, upgrade
|
||||
|
||||
-}
|
||||
|
||||
import Browser.Navigation exposing (Key)
|
||||
import Shared
|
||||
import Spa.Document as Document exposing (Document)
|
||||
import Spa.Url exposing (Url)
|
||||
import Url
|
||||
|
||||
|
||||
type alias Page params model msg =
|
||||
{ init : Shared.Model -> Url params -> ( model, Cmd msg )
|
||||
, update : msg -> model -> ( model, Cmd msg )
|
||||
, view : model -> Document msg
|
||||
, subscriptions : model -> Sub msg
|
||||
, save : model -> Shared.Model -> Shared.Model
|
||||
, load : Shared.Model -> model -> ( model, Cmd msg )
|
||||
}
|
||||
|
||||
|
||||
static :
|
||||
{ view : Url params -> Document msg
|
||||
}
|
||||
-> Page params (Url params) msg
|
||||
static page =
|
||||
{ init = \_ url -> ( url, Cmd.none )
|
||||
, update = \_ model -> ( model, Cmd.none )
|
||||
, view = page.view
|
||||
, subscriptions = \_ -> Sub.none
|
||||
, save = always identity
|
||||
, load = \_ model -> ( model, Cmd.none )
|
||||
}
|
||||
|
||||
|
||||
sandbox :
|
||||
{ init : Url params -> model
|
||||
, update : msg -> model -> model
|
||||
, view : model -> Document msg
|
||||
}
|
||||
-> Page params model msg
|
||||
sandbox page =
|
||||
{ init = \_ url -> ( page.init url, Cmd.none )
|
||||
, update = \msg model -> ( page.update msg model, Cmd.none )
|
||||
, view = page.view
|
||||
, subscriptions = \_ -> Sub.none
|
||||
, save = always identity
|
||||
, load = \_ model -> ( model, Cmd.none )
|
||||
}
|
||||
|
||||
|
||||
element :
|
||||
{ init : Url params -> ( model, Cmd msg )
|
||||
, update : msg -> model -> ( model, Cmd msg )
|
||||
, view : model -> Document msg
|
||||
, subscriptions : model -> Sub msg
|
||||
}
|
||||
-> Page params model msg
|
||||
element page =
|
||||
{ init = \_ params -> page.init params
|
||||
, update = \msg model -> page.update msg model
|
||||
, view = page.view
|
||||
, subscriptions = page.subscriptions
|
||||
, save = always identity
|
||||
, load = \_ model -> ( model, Cmd.none )
|
||||
}
|
||||
|
||||
|
||||
application :
|
||||
{ init : Shared.Model -> Url params -> ( model, Cmd msg )
|
||||
, update : msg -> model -> ( model, Cmd msg )
|
||||
, view : model -> Document msg
|
||||
, subscriptions : model -> Sub msg
|
||||
, save : model -> Shared.Model -> Shared.Model
|
||||
, load : Shared.Model -> model -> ( model, Cmd msg )
|
||||
}
|
||||
-> Page params model msg
|
||||
application page =
|
||||
page
|
||||
|
||||
|
||||
|
||||
-- UPGRADING
|
||||
|
||||
|
||||
type alias Upgraded pageParams pageModel pageMsg model msg =
|
||||
{ init : pageParams -> Shared.Model -> Key -> Url.Url -> ( model, Cmd msg )
|
||||
, update : pageMsg -> pageModel -> ( model, Cmd msg )
|
||||
, bundle : pageModel -> Bundle model msg
|
||||
}
|
||||
|
||||
|
||||
type alias Bundle model msg =
|
||||
{ view : Document msg
|
||||
, subscriptions : Sub msg
|
||||
, save : Shared.Model -> Shared.Model
|
||||
, load : Shared.Model -> ( model, Cmd msg )
|
||||
}
|
||||
|
||||
|
||||
upgrade :
|
||||
(pageModel -> model)
|
||||
-> (pageMsg -> msg)
|
||||
-> Page pageParams pageModel pageMsg
|
||||
-> Upgraded pageParams pageModel pageMsg model msg
|
||||
upgrade toModel toMsg page =
|
||||
{ init = \params shared key url -> page.init shared (Spa.Url.create params key url) |> Tuple.mapBoth toModel (Cmd.map toMsg)
|
||||
, update = \msg model -> page.update msg model |> Tuple.mapBoth toModel (Cmd.map toMsg)
|
||||
, bundle =
|
||||
\model ->
|
||||
{ view = page.view model |> Document.map toMsg
|
||||
, subscriptions = page.subscriptions model |> Sub.map toMsg
|
||||
, save = page.save model
|
||||
, load = \shared -> page.load shared model |> Tuple.mapBoth toModel (Cmd.map toMsg)
|
||||
}
|
||||
}
|
2
examples/elm-spa-dev/src/Spa/README.md
Normal file
2
examples/elm-spa-dev/src/Spa/README.md
Normal file
@ -0,0 +1,2 @@
|
||||
# src/Spa
|
||||
> elm-spa configuration and generated code
|
23
examples/elm-spa-dev/src/Spa/Transition.elm
Normal file
23
examples/elm-spa-dev/src/Spa/Transition.elm
Normal file
@ -0,0 +1,23 @@
|
||||
module Spa.Transition exposing
|
||||
( delays
|
||||
, properties
|
||||
)
|
||||
|
||||
|
||||
delays : { layout : Int, page : Int }
|
||||
delays =
|
||||
{ layout = 300
|
||||
, page = 300
|
||||
}
|
||||
|
||||
|
||||
properties : { layout : String, page : String }
|
||||
properties =
|
||||
{ layout = property delays.layout
|
||||
, page = property delays.page
|
||||
}
|
||||
|
||||
|
||||
property : Int -> String
|
||||
property delay =
|
||||
"opacity " ++ String.fromInt delay ++ "ms ease-in-out, visibility " ++ String.fromInt delay ++ "ms ease-in-out"
|
45
examples/elm-spa-dev/src/Spa/Url.elm
Normal file
45
examples/elm-spa-dev/src/Spa/Url.elm
Normal file
@ -0,0 +1,45 @@
|
||||
module Spa.Url exposing (Url, create)
|
||||
|
||||
import Browser.Navigation exposing (Key)
|
||||
import Dict exposing (Dict)
|
||||
import Url
|
||||
|
||||
|
||||
type alias Url params =
|
||||
{ key : Key
|
||||
, params : params
|
||||
, query : Dict String String
|
||||
, rawUrl : Url.Url
|
||||
}
|
||||
|
||||
|
||||
create : params -> Key -> Url.Url -> Url params
|
||||
create params key url =
|
||||
{ params = params
|
||||
, key = key
|
||||
, rawUrl = url
|
||||
, query =
|
||||
url.query
|
||||
|> Maybe.map toQueryDict
|
||||
|> Maybe.withDefault Dict.empty
|
||||
}
|
||||
|
||||
|
||||
toQueryDict : String -> Dict String String
|
||||
toQueryDict queryString =
|
||||
let
|
||||
second : List a -> Maybe a
|
||||
second list =
|
||||
list |> List.drop 1 |> List.head
|
||||
|
||||
toTuple : List String -> Maybe ( String, String )
|
||||
toTuple list =
|
||||
Maybe.map2 Tuple.pair
|
||||
(List.head list)
|
||||
(second list)
|
||||
in
|
||||
queryString
|
||||
|> String.split "&"
|
||||
|> List.map (String.split "=")
|
||||
|> List.filterMap toTuple
|
||||
|> Dict.fromList
|
16
examples/elm-spa-dev/src/Utils/Cmd.elm
Normal file
16
examples/elm-spa-dev/src/Utils/Cmd.elm
Normal file
@ -0,0 +1,16 @@
|
||||
module Utils.Cmd exposing (delay, send)
|
||||
|
||||
import Process
|
||||
import Task
|
||||
|
||||
|
||||
send : msg -> Cmd msg
|
||||
send =
|
||||
delay 0
|
||||
|
||||
|
||||
delay : Int -> msg -> Cmd msg
|
||||
delay ms msg =
|
||||
Process.sleep (toFloat ms)
|
||||
|> Task.map (\_ -> msg)
|
||||
|> Task.perform identity
|
2
examples/elm-spa-dev/src/Utils/README.md
Normal file
2
examples/elm-spa-dev/src/Utils/README.md
Normal file
@ -0,0 +1,2 @@
|
||||
# src/Utils
|
||||
> Helpful modules around data structures
|
8
examples/elm-spa-dev/src/Utils/String.elm
Normal file
8
examples/elm-spa-dev/src/Utils/String.elm
Normal file
@ -0,0 +1,8 @@
|
||||
module Utils.String exposing (sluggify)
|
||||
|
||||
|
||||
sluggify : String -> String
|
||||
sluggify words =
|
||||
words
|
||||
|> String.replace " " "-"
|
||||
|> String.toLower
|
24
examples/elm-spa-dev/tests/ProgramTests/NotFound.elm
Normal file
24
examples/elm-spa-dev/tests/ProgramTests/NotFound.elm
Normal file
@ -0,0 +1,24 @@
|
||||
module Program.NotFoundTest exposing (all)
|
||||
|
||||
import Pages.NotFound as Page
|
||||
import ProgramTest exposing (ProgramTest, expectViewHas)
|
||||
import Program.Utils.Spa
|
||||
import Test exposing (..)
|
||||
import Test.Html.Selector exposing (text)
|
||||
|
||||
|
||||
start : ProgramTest Page.Model Page.Msg (Cmd Page.Msg)
|
||||
start =
|
||||
Program.Utils.Spa.createStaticPage
|
||||
{ view = Page.view
|
||||
}
|
||||
|
||||
|
||||
all : Test
|
||||
all =
|
||||
describe "Pages.NotFound"
|
||||
[ test "should say page not found" <|
|
||||
\() ->
|
||||
start
|
||||
|> expectViewHas [ text "Page not found" ]
|
||||
]
|
2
examples/elm-spa-dev/tests/ProgramTests/README.md
Normal file
2
examples/elm-spa-dev/tests/ProgramTests/README.md
Normal file
@ -0,0 +1,2 @@
|
||||
# tests/Program
|
||||
> Write tests for pages
|
24
examples/elm-spa-dev/tests/ProgramTests/Top.elm
Normal file
24
examples/elm-spa-dev/tests/ProgramTests/Top.elm
Normal file
@ -0,0 +1,24 @@
|
||||
module Program.TopTest exposing (all)
|
||||
|
||||
import Pages.Top as Page
|
||||
import ProgramTest exposing (ProgramTest, expectViewHas)
|
||||
import Program.Utils.Spa
|
||||
import Test exposing (..)
|
||||
import Test.Html.Selector exposing (text)
|
||||
|
||||
|
||||
start : ProgramTest Page.Model Page.Msg (Cmd Page.Msg)
|
||||
start =
|
||||
Program.Utils.Spa.createStaticPage
|
||||
{ view = Page.view
|
||||
}
|
||||
|
||||
|
||||
all : Test
|
||||
all =
|
||||
describe "Pages.Top"
|
||||
[ test "should say homepage" <|
|
||||
\() ->
|
||||
start
|
||||
|> expectViewHas [ text "Homepage" ]
|
||||
]
|
54
examples/elm-spa-dev/tests/ProgramTests/Utils/Spa.elm
Normal file
54
examples/elm-spa-dev/tests/ProgramTests/Utils/Spa.elm
Normal file
@ -0,0 +1,54 @@
|
||||
module Program.Utils.Spa exposing
|
||||
( createElementPage
|
||||
, createSandboxPage
|
||||
, createStaticPage
|
||||
)
|
||||
|
||||
import ProgramTest exposing (ProgramTest)
|
||||
import Spa.Document exposing (Document)
|
||||
import Spa.Url exposing (Url)
|
||||
|
||||
|
||||
createStaticPage :
|
||||
{ view : Document msg
|
||||
}
|
||||
-> ProgramTest () msg (Cmd msg)
|
||||
createStaticPage page =
|
||||
ProgramTest.createDocument
|
||||
{ init = \_ -> ( (), Cmd.none )
|
||||
, update = \_ model -> ( model, Cmd.none )
|
||||
, view = \_ -> page.view |> Spa.Document.toBrowserDocument
|
||||
}
|
||||
|> ProgramTest.start ()
|
||||
|
||||
|
||||
createSandboxPage :
|
||||
{ init : model
|
||||
, update : msg -> model -> model
|
||||
, view : model -> Document msg
|
||||
}
|
||||
-> ProgramTest model msg (Cmd msg)
|
||||
createSandboxPage page =
|
||||
ProgramTest.createDocument
|
||||
{ init = \_ -> ( page.init, Cmd.none )
|
||||
, update = \msg model -> ( page.update msg model, Cmd.none )
|
||||
, view = page.view >> Spa.Document.toBrowserDocument
|
||||
}
|
||||
|> ProgramTest.start ()
|
||||
|
||||
|
||||
createElementPage :
|
||||
Url params
|
||||
->
|
||||
{ init : Url params -> ( model, Cmd msg )
|
||||
, update : msg -> model -> ( model, Cmd msg )
|
||||
, view : model -> Document msg
|
||||
}
|
||||
-> ProgramTest model msg (Cmd msg)
|
||||
createElementPage params page =
|
||||
ProgramTest.createDocument
|
||||
{ init = page.init
|
||||
, update = page.update
|
||||
, view = page.view >> Spa.Document.toBrowserDocument
|
||||
}
|
||||
|> ProgramTest.start params
|
2
examples/elm-spa-dev/tests/README.md
Normal file
2
examples/elm-spa-dev/tests/README.md
Normal file
@ -0,0 +1,2 @@
|
||||
# tests
|
||||
> A place for function and program tests
|
2
examples/elm-spa-dev/tests/UnitTests/README.md
Normal file
2
examples/elm-spa-dev/tests/UnitTests/README.md
Normal file
@ -0,0 +1,2 @@
|
||||
# tests/Unit
|
||||
> Write tests for functions
|
6
examples/elm-ui/.gitignore
vendored
6
examples/elm-ui/.gitignore
vendored
@ -1,6 +0,0 @@
|
||||
.DS_Store
|
||||
dist
|
||||
elm-stuff
|
||||
node_modules
|
||||
|
||||
src/Generated
|
@ -1,18 +0,0 @@
|
||||
# examples/elm-ui
|
||||
|
||||
```
|
||||
npm start
|
||||
```
|
||||
|
||||
## how i upgraded to elm-ui
|
||||
|
||||
```
|
||||
npm install -g elm-spa
|
||||
elm-spa init my-project
|
||||
cd my-project
|
||||
elm install mdgriffith/elm-ui
|
||||
```
|
||||
|
||||
Checkout the `src/Page.elm` and `src/Document.elm` files, they allow us to create pages with `Element msg` and `Html msg`
|
||||
|
||||
From there, I just replaced `Html` with `Element` in the `src/Pages/*.elm` files.
|
@ -1,26 +0,0 @@
|
||||
{
|
||||
"type": "application",
|
||||
"source-directories": [
|
||||
"src",
|
||||
"../../src"
|
||||
],
|
||||
"elm-version": "0.19.1",
|
||||
"dependencies": {
|
||||
"direct": {
|
||||
"elm/browser": "1.0.2",
|
||||
"elm/core": "1.0.5",
|
||||
"elm/html": "1.0.0",
|
||||
"elm/url": "1.0.0",
|
||||
"mdgriffith/elm-ui": "1.1.5"
|
||||
},
|
||||
"indirect": {
|
||||
"elm/json": "1.1.3",
|
||||
"elm/time": "1.0.0",
|
||||
"elm/virtual-dom": "1.0.2"
|
||||
}
|
||||
},
|
||||
"test-dependencies": {
|
||||
"direct": {},
|
||||
"indirect": {}
|
||||
}
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "elm-spa-app",
|
||||
"version": "1.0.0",
|
||||
"description": "my new elm-spa application",
|
||||
"main": "public/index.html",
|
||||
"scripts": {
|
||||
"start": "npm install && npm run build && npm run dev",
|
||||
"build": "npm run build:elm-spa && npm run build:elm",
|
||||
"build:elm-spa": "elm-spa build .",
|
||||
"build:elm": "elm make src/Main.elm --optimize --output public/dist/elm.js",
|
||||
"dev": "concurrently --raw --kill-others \"npm run dev:elm-spa\" \"npm run dev:elm\"",
|
||||
"dev:elm-spa": "chokidar src/Pages -c \"npm run build:elm-spa\"",
|
||||
"dev:elm": "elm-live src/Main.elm -u -d public -- --debug --output public/dist/elm.js"
|
||||
},
|
||||
"keywords": [
|
||||
"elm",
|
||||
"spa"
|
||||
],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"chokidar-cli": "2.1.0",
|
||||
"concurrently": "5.0.0",
|
||||
"elm": "0.19.1-3",
|
||||
"elm-live": "4.0.2",
|
||||
"elm-spa": "4.1.0"
|
||||
}
|
||||
}
|
@ -1,86 +0,0 @@
|
||||
module Components exposing (layout)
|
||||
|
||||
import Document exposing (Document)
|
||||
import Element exposing (..)
|
||||
import Element.Background as Background
|
||||
import Element.Border as Border
|
||||
import Element.Font as Font
|
||||
import Generated.Route as Route exposing (Route)
|
||||
|
||||
|
||||
layout : { page : Document msg } -> Document msg
|
||||
layout { page } =
|
||||
{ title = page.title
|
||||
, body =
|
||||
[ column [ spacing 32, padding 20, width (fill |> maximum 780), height fill, centerX ]
|
||||
[ navbar
|
||||
, column [ height fill ] page.body
|
||||
, footer
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
navbar : Element msg
|
||||
navbar =
|
||||
row [ width fill ]
|
||||
[ el [ Font.size 24, Font.bold ] <| link ( "home", Route.Top )
|
||||
, row [ alignRight, spacing 20 ]
|
||||
[ link ( "docs", Route.Docs )
|
||||
, link ( "a broken link", Route.NotFound )
|
||||
, externalButtonLink ( "tweet about it", "https://twitter.com/intent/tweet?text=elm-spa is ez pz" )
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
link : ( String, Route ) -> Element msg
|
||||
link ( label, route ) =
|
||||
Element.link styles.link
|
||||
{ label = text label
|
||||
, url = Route.toHref route
|
||||
}
|
||||
|
||||
|
||||
externalButtonLink : ( String, String ) -> Element msg
|
||||
externalButtonLink ( label, url ) =
|
||||
Element.newTabLink styles.button
|
||||
{ label = text label
|
||||
, url = url
|
||||
}
|
||||
|
||||
|
||||
footer : Element msg
|
||||
footer =
|
||||
row [] [ text "built with elm ❤" ]
|
||||
|
||||
|
||||
|
||||
-- STYLES
|
||||
|
||||
|
||||
colors : { blue : Color, white : Color, red : Color }
|
||||
colors =
|
||||
{ white = rgb 1 1 1
|
||||
, red = rgb255 204 85 68
|
||||
, blue = rgb255 50 100 150
|
||||
}
|
||||
|
||||
|
||||
styles :
|
||||
{ link : List (Element.Attribute msg)
|
||||
, button : List (Element.Attribute msg)
|
||||
}
|
||||
styles =
|
||||
{ link =
|
||||
[ Font.underline
|
||||
, Font.color colors.blue
|
||||
, mouseOver [ alpha 0.6 ]
|
||||
]
|
||||
, button =
|
||||
[ Font.color colors.white
|
||||
, Background.color colors.red
|
||||
, Border.rounded 4
|
||||
, paddingXY 24 10
|
||||
, mouseOver [ alpha 0.6 ]
|
||||
]
|
||||
}
|
@ -1,98 +0,0 @@
|
||||
module Global exposing
|
||||
( Flags
|
||||
, Model
|
||||
, Msg
|
||||
, init
|
||||
, navigate
|
||||
, subscriptions
|
||||
, update
|
||||
, view
|
||||
)
|
||||
|
||||
import Browser.Navigation as Nav
|
||||
import Components
|
||||
import Document exposing (Document)
|
||||
import Generated.Route as Route exposing (Route)
|
||||
import Task
|
||||
import Url exposing (Url)
|
||||
|
||||
|
||||
|
||||
-- INIT
|
||||
|
||||
|
||||
type alias Flags =
|
||||
()
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ flags : Flags
|
||||
, url : Url
|
||||
, key : Nav.Key
|
||||
}
|
||||
|
||||
|
||||
init : Flags -> Url -> Nav.Key -> ( Model, Cmd Msg )
|
||||
init flags url key =
|
||||
( Model
|
||||
flags
|
||||
url
|
||||
key
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- UPDATE
|
||||
|
||||
|
||||
type Msg
|
||||
= Navigate Route
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
Navigate route ->
|
||||
( model
|
||||
, Nav.pushUrl model.key (Route.toHref route)
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- SUBSCRIPTIONS
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
Sub.none
|
||||
|
||||
|
||||
|
||||
-- VIEW
|
||||
|
||||
|
||||
view :
|
||||
{ page : Document msg
|
||||
, global : Model
|
||||
, toMsg : Msg -> msg
|
||||
}
|
||||
-> Document msg
|
||||
view { page, global, toMsg } =
|
||||
Components.layout
|
||||
{ page = page
|
||||
}
|
||||
|
||||
|
||||
|
||||
-- COMMANDS
|
||||
|
||||
|
||||
send : msg -> Cmd msg
|
||||
send =
|
||||
Task.succeed >> Task.perform identity
|
||||
|
||||
|
||||
navigate : Route -> Cmd Msg
|
||||
navigate route =
|
||||
send (Navigate route)
|
@ -1,133 +0,0 @@
|
||||
module Main exposing (main)
|
||||
|
||||
import Browser
|
||||
import Browser.Navigation as Nav exposing (Key)
|
||||
import Document exposing (Document)
|
||||
import Element
|
||||
import Generated.Pages as Pages
|
||||
import Generated.Route as Route exposing (Route)
|
||||
import Global
|
||||
import Url exposing (Url)
|
||||
|
||||
|
||||
main : Program Flags Model Msg
|
||||
main =
|
||||
Browser.application
|
||||
{ init = init
|
||||
, view = view
|
||||
, update = update
|
||||
, subscriptions = subscriptions
|
||||
, onUrlRequest = LinkClicked
|
||||
, onUrlChange = UrlChanged
|
||||
}
|
||||
|
||||
|
||||
|
||||
-- INIT
|
||||
|
||||
|
||||
type alias Flags =
|
||||
()
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ key : Key
|
||||
, url : Url
|
||||
, global : Global.Model
|
||||
, page : Pages.Model
|
||||
}
|
||||
|
||||
|
||||
init : Flags -> Url -> Key -> ( Model, Cmd Msg )
|
||||
init flags url key =
|
||||
let
|
||||
( global, globalCmd ) =
|
||||
Global.init flags url key
|
||||
|
||||
( page, pageCmd, pageGlobalCmd ) =
|
||||
Pages.init (fromUrl url) global
|
||||
in
|
||||
( Model key url global page
|
||||
, Cmd.batch
|
||||
[ Cmd.map Global globalCmd
|
||||
, Cmd.map Global pageGlobalCmd
|
||||
, Cmd.map Page pageCmd
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
type Msg
|
||||
= LinkClicked Browser.UrlRequest
|
||||
| UrlChanged Url
|
||||
| Global Global.Msg
|
||||
| Page Pages.Msg
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
LinkClicked (Browser.Internal url) ->
|
||||
( model, Nav.pushUrl model.key (Url.toString url) )
|
||||
|
||||
LinkClicked (Browser.External href) ->
|
||||
( model, Nav.load href )
|
||||
|
||||
UrlChanged url ->
|
||||
let
|
||||
( page, pageCmd, globalCmd ) =
|
||||
Pages.init (fromUrl url) model.global
|
||||
in
|
||||
( { model | url = url, page = page }
|
||||
, Cmd.batch
|
||||
[ Cmd.map Page pageCmd
|
||||
, Cmd.map Global globalCmd
|
||||
]
|
||||
)
|
||||
|
||||
Global globalMsg ->
|
||||
let
|
||||
( global, globalCmd ) =
|
||||
Global.update globalMsg model.global
|
||||
in
|
||||
( { model | global = global }
|
||||
, Cmd.map Global globalCmd
|
||||
)
|
||||
|
||||
Page pageMsg ->
|
||||
let
|
||||
( page, pageCmd, globalCmd ) =
|
||||
Pages.update pageMsg model.page model.global
|
||||
in
|
||||
( { model | page = page }
|
||||
, Cmd.batch
|
||||
[ Cmd.map Page pageCmd
|
||||
, Cmd.map Global globalCmd
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
Sub.batch
|
||||
[ model.global
|
||||
|> Global.subscriptions
|
||||
|> Sub.map Global
|
||||
, model.page
|
||||
|> (\page -> Pages.subscriptions page model.global)
|
||||
|> Sub.map Page
|
||||
]
|
||||
|
||||
|
||||
view : Model -> Browser.Document Msg
|
||||
view model =
|
||||
Document.toBrowserDocument <|
|
||||
Global.view
|
||||
{ page = Pages.view model.page model.global |> Document.map Page
|
||||
, global = model.global
|
||||
, toMsg = Global
|
||||
}
|
||||
|
||||
|
||||
fromUrl : Url -> Route
|
||||
fromUrl =
|
||||
Route.fromUrl >> Maybe.withDefault Route.NotFound
|
@ -1,82 +0,0 @@
|
||||
module Page exposing
|
||||
( Page, Document, Bundle
|
||||
, upgrade
|
||||
, static, sandbox, element, component
|
||||
)
|
||||
|
||||
{-|
|
||||
|
||||
@docs Page, Document, Bundle
|
||||
|
||||
@docs upgrade
|
||||
|
||||
@docs static, sandbox, element, component
|
||||
|
||||
-}
|
||||
|
||||
import Browser
|
||||
import Document
|
||||
import Global
|
||||
import Spa.Advanced as Spa
|
||||
|
||||
|
||||
type alias Document msg =
|
||||
Document.Document msg
|
||||
|
||||
|
||||
type alias Page flags model msg =
|
||||
Spa.Page flags model msg Global.Model Global.Msg (Document msg)
|
||||
|
||||
|
||||
type alias Bundle msg =
|
||||
Spa.Bundle msg (Document msg)
|
||||
|
||||
|
||||
upgrade :
|
||||
(pageModel -> model)
|
||||
-> (pageMsg -> msg)
|
||||
-> Page pageFlags pageModel pageMsg
|
||||
->
|
||||
{ init : pageFlags -> Global.Model -> ( model, Cmd msg, Cmd Global.Msg )
|
||||
, update : pageMsg -> pageModel -> Global.Model -> ( model, Cmd msg, Cmd Global.Msg )
|
||||
, bundle : pageModel -> Global.Model -> Bundle msg
|
||||
}
|
||||
upgrade =
|
||||
Spa.upgrade Document.map
|
||||
|
||||
|
||||
static : { view : Document msg } -> Page flags () msg
|
||||
static =
|
||||
Spa.static
|
||||
|
||||
|
||||
sandbox :
|
||||
{ init : model
|
||||
, update : msg -> model -> model
|
||||
, view : model -> Document msg
|
||||
}
|
||||
-> Page flags model msg
|
||||
sandbox =
|
||||
Spa.sandbox
|
||||
|
||||
|
||||
element :
|
||||
{ init : flags -> ( model, Cmd msg )
|
||||
, update : msg -> model -> ( model, Cmd msg )
|
||||
, subscriptions : model -> Sub msg
|
||||
, view : model -> Document msg
|
||||
}
|
||||
-> Page flags model msg
|
||||
element =
|
||||
Spa.element
|
||||
|
||||
|
||||
component :
|
||||
{ init : Global.Model -> flags -> ( model, Cmd msg, Cmd Global.Msg )
|
||||
, update : Global.Model -> msg -> model -> ( model, Cmd msg, Cmd Global.Msg )
|
||||
, subscriptions : Global.Model -> model -> Sub msg
|
||||
, view : Global.Model -> model -> Document msg
|
||||
}
|
||||
-> Page flags model msg
|
||||
component =
|
||||
Spa.component
|
@ -1,30 +0,0 @@
|
||||
module Pages.Docs exposing (Flags, Model, Msg, page)
|
||||
|
||||
import Element
|
||||
import Page exposing (Document, Page)
|
||||
|
||||
|
||||
type alias Flags =
|
||||
()
|
||||
|
||||
|
||||
type alias Model =
|
||||
()
|
||||
|
||||
|
||||
type alias Msg =
|
||||
Never
|
||||
|
||||
|
||||
page : Page Flags Model Msg
|
||||
page =
|
||||
Page.static
|
||||
{ view = view
|
||||
}
|
||||
|
||||
|
||||
view : Document Msg
|
||||
view =
|
||||
{ title = "Docs"
|
||||
, body = [ Element.text "Docs" ]
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
module Pages.NotFound exposing (Flags, Model, Msg, page)
|
||||
|
||||
import Element
|
||||
import Page exposing (Document, Page)
|
||||
|
||||
|
||||
type alias Flags =
|
||||
()
|
||||
|
||||
|
||||
type alias Model =
|
||||
()
|
||||
|
||||
|
||||
type alias Msg =
|
||||
Never
|
||||
|
||||
|
||||
page : Page Flags Model Msg
|
||||
page =
|
||||
Page.static
|
||||
{ view = view
|
||||
}
|
||||
|
||||
|
||||
view : Document Msg
|
||||
view =
|
||||
{ title = "NotFound"
|
||||
, body = [ Element.text "NotFound" ]
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
module Pages.Top exposing (Flags, Model, Msg, page)
|
||||
|
||||
import Element
|
||||
import Page exposing (Document, Page)
|
||||
|
||||
|
||||
type alias Flags =
|
||||
()
|
||||
|
||||
|
||||
type alias Model =
|
||||
()
|
||||
|
||||
|
||||
type alias Msg =
|
||||
Never
|
||||
|
||||
|
||||
page : Page Flags Model Msg
|
||||
page =
|
||||
Page.static
|
||||
{ view = view
|
||||
}
|
||||
|
||||
|
||||
view : Document Msg
|
||||
view =
|
||||
{ title = "Top"
|
||||
, body = [ Element.text "Top" ]
|
||||
}
|
@ -1,841 +0,0 @@
|
||||
# from scratch
|
||||
> a guide on how to use elm-spa without the cli
|
||||
|
||||
## getting setup
|
||||
|
||||
1. Install [elm](https://guide.elm-lang.org/install/elm.html)
|
||||
|
||||
1. Optional: Install [VS Code](https://code.visualstudio.com/download) and the [elm extension](https://marketplace.visualstudio.com/items?itemName=Elmtooling.elm-ls-vscode)
|
||||
|
||||
## creating a new project
|
||||
|
||||
Create a new Elm project using the `elm init` command:
|
||||
|
||||
```terminal
|
||||
elm init
|
||||
```
|
||||
|
||||
This will create an `elm.json` file and a `src` folder. Let's install two more packages for our project:
|
||||
|
||||
```
|
||||
elm install elm/url
|
||||
elm install ryannhg/elm-spa
|
||||
```
|
||||
|
||||
## src/Main.elm
|
||||
|
||||
First, we create a new file called `src/Main.elm`- which is the entrypoint for our new Elm application. Let's build it together:
|
||||
|
||||
### imports and main
|
||||
|
||||
We need to create a `main` function for Elm to call
|
||||
when our application starts up. `Browser.application` supports
|
||||
client-side routing, so that's the function we'll want to
|
||||
call for our single page application.
|
||||
|
||||
```elm
|
||||
module Main exposing (main)
|
||||
|
||||
import Browser
|
||||
import Browser.Navigation as Nav
|
||||
import Document exposing (Document)
|
||||
import Global
|
||||
import Pages
|
||||
import Url exposing (Url)
|
||||
|
||||
|
||||
main : Program Global.Flags Model Msg
|
||||
main =
|
||||
Browser.application
|
||||
{ init = init
|
||||
, update = update
|
||||
, subscriptions = subscriptions
|
||||
, view = view
|
||||
, onUrlRequest = LinkChanged
|
||||
, onUrlChange = UrlChanged
|
||||
}
|
||||
```
|
||||
|
||||
The `Document`, `Global`, and `Pages` modules haven't been created yet, but `Browser`, `Browser.Navigation`, and `Url` come from the `elm/browser` and `elm/url` packages.
|
||||
|
||||
|
||||
### model and init
|
||||
|
||||
Here we'll store four things:
|
||||
|
||||
1. The current URL
|
||||
|
||||
1. A unique key used for navigation
|
||||
|
||||
1. The global state of our app
|
||||
|
||||
1. The state of the page we are currently viewing
|
||||
|
||||
```elm
|
||||
type alias Model =
|
||||
{ url : Url
|
||||
, key : Nav.Key
|
||||
, global : Global.Model
|
||||
, page : Pages.Model
|
||||
}
|
||||
```
|
||||
|
||||
We'll implement `Global.Model` and `Pages.Model` soon,
|
||||
but for now let's continue by implementing the `init`
|
||||
function.
|
||||
|
||||
```elm
|
||||
init : Flags -> Url -> Key -> ( Model, Cmd Msg )
|
||||
init flags url key =
|
||||
let
|
||||
( globalModel, globalCmd ) =
|
||||
Global.init flags url key
|
||||
|
||||
( pageModel, pageCmd, pageGlobalCmd ) =
|
||||
Pages.init url globalModel
|
||||
in
|
||||
( Model url key globalModel pagesModel
|
||||
, Cmd.batch
|
||||
[ Cmd.map FromGlobal globalCmd
|
||||
, Cmd.map FromGlobal pageGlobalCmd
|
||||
, Cmd.map FromPage pageCmd
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
Here we assume `Global.init` handles the initialization of the global state between pages, and can send a global command like an HTTP request or another side effect.
|
||||
|
||||
Similarly, `Pages.init` initializes the current page based on the current URL and the initialize global state. In addition to returning `Cmd Pages.Msg`, pages can also return `Cmd Global.Msg`. This allows them to affect the global state shared between pages.
|
||||
|
||||
We'll implement those functions together later!
|
||||
|
||||
### msg and update
|
||||
|
||||
```elm
|
||||
type Msg
|
||||
= LinkClicked Browser.UrlRequest
|
||||
| UrlChanged Url
|
||||
| FromGlobal Global.Msg
|
||||
| FromPage Page.Msg
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
LinkClicked (Browser.Internal url) ->
|
||||
( model
|
||||
, Nav.pushUrl model.key (Url.toString url)
|
||||
)
|
||||
|
||||
LinkClicked (Browser.External href) ->
|
||||
( model
|
||||
, Nav.load href
|
||||
)
|
||||
|
||||
UrlChanged url ->
|
||||
let
|
||||
( pageModel, pageCmd, pageGlobalCmd ) =
|
||||
Pages.init url model.global
|
||||
in
|
||||
( { model | url = url, page = pageModel }
|
||||
, Cmd.batch
|
||||
[ Cmd.map FromGlobal pageGlobalCmd
|
||||
, Cmd.map FromPage pageCmd
|
||||
]
|
||||
)
|
||||
|
||||
FromGlobal globalMsg ->
|
||||
let
|
||||
( globalModel, globalCmd ) =
|
||||
Global.update globalMsg model.global
|
||||
in
|
||||
( { model | global = globalModel }
|
||||
, Cmd.map FromGlobal globalCmd
|
||||
)
|
||||
|
||||
FromPages pageMsg ->
|
||||
let
|
||||
( pageModel, pageCmd, pageGlobalCmd ) =
|
||||
Pages.update pageMsg model.page model.global
|
||||
in
|
||||
( { model | page = pageModel }
|
||||
, Cmd.batch
|
||||
[ Cmd.map FromGlobal pageGlobalCmd
|
||||
, Cmd.map FromPage pageCmd
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
Here we define and handle the four messages we might receive from the user:
|
||||
|
||||
1. They click a link, either internal or external, and we navigate to the correct page.
|
||||
|
||||
1. The URL changes and we need to initialize the new page.
|
||||
|
||||
1. We received an update for the global state between pages.
|
||||
|
||||
1. We received an update for a page.
|
||||
|
||||
|
||||
### view + subscriptions
|
||||
|
||||
The view and subscriptions for the page are based on the current `Model` of the application:
|
||||
|
||||
```elm
|
||||
view : Model -> Document Msg
|
||||
view model =
|
||||
Global.view
|
||||
{ global = model.global
|
||||
, fromGlobal = FromGlobal
|
||||
, page = Document.map FromPage (Pages.view model.page model.global)
|
||||
}
|
||||
```
|
||||
|
||||
The `view` function calls the global layout, and provides three things:
|
||||
|
||||
1. The current global state of the application.
|
||||
1. A way to upgrade a `Global.Msg` into a `Msg`.
|
||||
1. The page's view, so it can decide where to render the page.
|
||||
|
||||
#### why those three values?
|
||||
|
||||
The pages within our app will return `Document Page.Msg`.
|
||||
|
||||
But we want components in our global view to be able to send global messages (`Global.Msg`).
|
||||
|
||||
In Elm, our function cannot return both `Document Page.Msg` and `Document Global.Msg`. To make sure our view function returns the same type:
|
||||
|
||||
1. We call `Document.map FromPage` to convert `Document Pages.Msg` into `Document Msg`
|
||||
1. We provide `fromGlobal` to `Global.view`, so it can convert `Global.Msg` values into `Msg` values!
|
||||
|
||||
Now we are returning `Document Msg` to `Main.view`, and we can handle messages from different sources. Later, we'll see `Global.view`- which will have a more concrete example for how to use those three values.
|
||||
|
||||
For now, let's wrap up by handling subscriptions!
|
||||
|
||||
```elm
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
Sub.batch
|
||||
[ Sub.map FromGlobal (Global.subscriptions model.global)
|
||||
, Sub.map FromPage (Pages.subscriptions model.page model.global)
|
||||
]
|
||||
```
|
||||
|
||||
Finally, we subscribe to events from the global and page modules, in case we want to track things like window resize or keyboard events.
|
||||
|
||||
That's it! The source code for src/Main.elm file is available [here](#todo).
|
||||
|
||||
## src/Document.elm
|
||||
|
||||
Before we get into `Global` and `Pages`, let's define the `Document` module, which defines what our pages should be returning:
|
||||
|
||||
```elm
|
||||
module Document exposing (Document, map)
|
||||
|
||||
import Html exposing (Html)
|
||||
|
||||
|
||||
type alias Document msg =
|
||||
{ title : String
|
||||
, body : List (Html msg)
|
||||
}
|
||||
|
||||
|
||||
map : (msg1 -> msg2) -> Document msg1 -> Document msg2
|
||||
map fn doc =
|
||||
{ title = doc.title
|
||||
, body = List.map (Html.map fn) doc.body
|
||||
}
|
||||
```
|
||||
|
||||
That's all we need for this file! We just redefined `Browser.Document` and created a `map` function a way to go from one `Document` to another. The `map` function is the one we used above in `src/Main.elm`.
|
||||
|
||||
|
||||
## src/Global.elm
|
||||
|
||||
The `Global` module initializes, updates, views, and handles subscriptions for the state we want to share across pages.
|
||||
|
||||
For example, if a user logs in, we don't want navigating from page to page to log them back out!
|
||||
|
||||
For that reason, it's nice to have a `Global.Model` for our application.
|
||||
|
||||
```elm
|
||||
module Global exposing
|
||||
( Flags
|
||||
, Model
|
||||
, Msg
|
||||
, init
|
||||
, update
|
||||
, view
|
||||
, subscriptions
|
||||
-- commands
|
||||
, increment
|
||||
, navigateTo
|
||||
)
|
||||
|
||||
import Browser.Navigation as Nav
|
||||
import Document exposing (Document)
|
||||
import Html exposing (Html)
|
||||
import Html.Attributes exposing (class)
|
||||
import Url exposing (Url)
|
||||
import Route exposing (Route)
|
||||
import Task
|
||||
```
|
||||
|
||||
### flags, model, and init
|
||||
|
||||
```elm
|
||||
type alias Flags =
|
||||
()
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ key : Nav.Key
|
||||
, counter : Int
|
||||
}
|
||||
|
||||
|
||||
init : Flags -> Url -> Nav.Key -> ( Model, Cmd Msg )
|
||||
init _ _ key =
|
||||
( Model key 0
|
||||
, Cmd.none
|
||||
)
|
||||
```
|
||||
|
||||
The `Flags` type is the data we pass in from JavaScript when starting our Elm application. Here we are using an empty tuple `()` to say that we won't be taking in any data from JS.
|
||||
|
||||
The `Global.Model` needs to store `Nav.Key` so it can navigate between pages. For that reason, we ignore the flags and url, but store `key` to our model.
|
||||
|
||||
For this example application, we're also storing a global `counter` value. Later, we'll demonstrate how to change that global counter from both pages and global components!
|
||||
|
||||
|
||||
### msg and update
|
||||
|
||||
```elm
|
||||
type Msg
|
||||
= NavigatedTo Route
|
||||
| Increment
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
NavigatedTo route ->
|
||||
( model
|
||||
, Nav.pushUrl model.key (Route.toHref route)
|
||||
)
|
||||
|
||||
Increment ->
|
||||
( { model | counter = model.counter + 1 }
|
||||
, Cmd.none
|
||||
)
|
||||
```
|
||||
|
||||
Here we handle `NavigatedTo`, a message that comes with a `Route`. A `Route` is just a custom type that holds all the URLs our application accepts. We'll define the `Route` module after this one!
|
||||
|
||||
When we get the `NavigatedTo` message from our application, we use the `Route.toHref` function to convert our route to a `String`. Then we can call `Nav.pushUrl` to change the URL for our app.
|
||||
|
||||
We also include an implementation for `Increment`. We'll see an example of changing global state soon.
|
||||
|
||||
#### global commands
|
||||
|
||||
To allow pages to send `Cmd Global.Msg`, we need to expose the commands we want our users to call.
|
||||
|
||||
Here we'll define a helper function called `send` that turns a `Global.Msg` into a `Cmd Global.Msg`:
|
||||
|
||||
```elm
|
||||
send : Msg -> Cmd Msg
|
||||
send msg =
|
||||
Task.succeed msg |> Task.perform identity
|
||||
```
|
||||
|
||||
From there, we can expose commands easily:
|
||||
|
||||
```elm
|
||||
navigateTo : Route -> Cmd Msg
|
||||
navigateTo route =
|
||||
send (NavigatedTo route)
|
||||
```
|
||||
|
||||
```elm
|
||||
increment : Cmd Msg
|
||||
increment =
|
||||
send Increment
|
||||
```
|
||||
|
||||
|
||||
### view + subscriptions
|
||||
|
||||
Our view function received the three pieces of information we passed in from `Main.view`.
|
||||
|
||||
```elm
|
||||
view :
|
||||
{ global : Model
|
||||
, toMsg : Msg -> msg
|
||||
, page : Document msg
|
||||
}
|
||||
-> Document msg
|
||||
view { global, toMsg, page } =
|
||||
{ title = page.title
|
||||
, body =
|
||||
[ Html.div [ class "layout" ]
|
||||
[ navbar
|
||||
{ increment = toMsg Increment
|
||||
, counter = global.counter
|
||||
}
|
||||
, Html.div [ class "page" ] page.body
|
||||
, footer
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
navbar :
|
||||
{ increment : msg
|
||||
, counter : Int
|
||||
}
|
||||
-> Html msg
|
||||
navbar options =
|
||||
Html.header [ class "navbar" ]
|
||||
[ Html.text (String.fromInt options.counter)
|
||||
, Html.button
|
||||
[ Events.onClick options.increment ]
|
||||
[ Html.text "+" ]
|
||||
]
|
||||
|
||||
|
||||
footer : Html msg
|
||||
footer =
|
||||
Html.footer [ class "footer" ] [ text "built with elm!" ]
|
||||
```
|
||||
|
||||
The `view` function has access to the current `global` model, `page`, and shared components can even send messages with the help of `toMsg`.
|
||||
|
||||
The `page` document's title is used by our view.
|
||||
|
||||
By passing in the `page` value, our layout can decide where to render the
|
||||
page view. Here we render it in between two components: navbar and footer
|
||||
|
||||
Our `navbar` is able to access the current `counter` value and can send the `increment` message when the button is clicked.
|
||||
|
||||
Here we use `toMsg` to make sure that the return type is `Html msg` instead of `Html Msg`. Using `toMsg` allows us to put the navbar alongside the `page.body` and `footer`- because they all return `Html msg`!
|
||||
|
||||
```elm
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions _ =
|
||||
Sub.none
|
||||
```
|
||||
|
||||
Our application doesn't require any subscriptions, but you could add them in here if you need them later!
|
||||
|
||||
That's `src/Global.elm`. The complete source code is [available here](#todo).
|
||||
|
||||
|
||||
## src/Route.elm
|
||||
|
||||
Earlier in our `Global` module, we used `Route` to handle page navigation messages. Let's define a `Route` module for this application, so we can see how it works.
|
||||
|
||||
```elm
|
||||
module Route exposing (Route(..), fromUrl, toHref)
|
||||
|
||||
import Url exposing (Url)
|
||||
import Url.Parser exposing (Parser, (</>), string, s)
|
||||
```
|
||||
|
||||
Our route module will be using a `Url.Parser` from the `elm/url` package to implement `fromUrl`.
|
||||
|
||||
### Route
|
||||
|
||||
```elm
|
||||
type Route
|
||||
= Home
|
||||
| AboutUs
|
||||
| Posts
|
||||
| Post String
|
||||
| NotFound
|
||||
```
|
||||
|
||||
When we define this `Route` type, we're saying that these are the only five pages a user can ever be on while using our application.
|
||||
|
||||
### fromUrl
|
||||
|
||||
We can create our `Route` by parsing a `Url`.
|
||||
|
||||
```elm
|
||||
fromUrl : Url -> Route
|
||||
fromUrl url =
|
||||
[ Parser.map Home Parser.top
|
||||
, Parser.map AboutUs (Parser.s "about-us")
|
||||
, Parser.map Posts (Parser.s "posts")
|
||||
, Parser.map Post (Parser.s "posts" </> Parser.string)
|
||||
]
|
||||
|> Parser.oneOf
|
||||
|> Parser.run url
|
||||
|> Maybe.withDefault NotFound
|
||||
```
|
||||
|
||||
Here we try to find a route match. If we can't find one, we default to `NotFound`. That way we always have a `Route` no matter what `Url` we are given.
|
||||
|
||||
### toHref
|
||||
|
||||
If we want to turn our `AboutUs` route back into `/about-us`, we'll need to define a function to do that for us:
|
||||
|
||||
```elm
|
||||
toHref : Route -> String
|
||||
toHref route =
|
||||
case route of
|
||||
Home ->
|
||||
"/"
|
||||
|
||||
AboutUs ->
|
||||
"/about-us"
|
||||
|
||||
Posts ->
|
||||
"/posts"
|
||||
|
||||
Post id ->
|
||||
"/posts/" ++ id
|
||||
|
||||
NotFound ->
|
||||
"/not-found"
|
||||
```
|
||||
|
||||
This function allows all our URLs to be handled in one place in the application.
|
||||
|
||||
|
||||
## src/Pages.elm
|
||||
|
||||
Now we are ready to take a look at how `ryannhg/elm-spa` allows us to compose together our pages.
|
||||
|
||||
Let's define the top-level `Pages` module that handles initializing, updating, viewing, and receiving subscriptions from the current page.
|
||||
|
||||
```elm
|
||||
import Pages exposing
|
||||
( Model
|
||||
, Msg
|
||||
, init
|
||||
, update
|
||||
, view
|
||||
, subscriptions
|
||||
)
|
||||
|
||||
import Global
|
||||
import Document exposing (Document)
|
||||
import Page exposing (Page)
|
||||
import Pages.Home as Home
|
||||
import Pages.AboutUs as AboutUs
|
||||
import Pages.Posts as Posts
|
||||
import Pages.Post as Post
|
||||
import Pages.NotFound as NotFound
|
||||
import Route
|
||||
import Url exposing (Url)
|
||||
```
|
||||
|
||||
### model, msg, and upgraded pages
|
||||
|
||||
The user can only be on one page at a time, so we use a custom type to represent which page we are currently viewing.
|
||||
|
||||
```elm
|
||||
type Model
|
||||
= Home_Model Home.Model
|
||||
| AboutUs_Model AboutUs.Model
|
||||
| Posts_Model Posts.Model
|
||||
| Post_Model Post.Model
|
||||
| NotFound_Model NotFound.Model
|
||||
```
|
||||
|
||||
Additionally, messages could be sent from any one of the five pages.
|
||||
|
||||
```elm
|
||||
type Msg
|
||||
= Home_Msg Home.Msg
|
||||
| AboutUs_Msg AboutUs.Msg
|
||||
| Posts_Msg Posts.Msg
|
||||
| Post_Msg Post.Msg
|
||||
| NotFound_Msg NotFound.Msg
|
||||
```
|
||||
|
||||
What's great about the custom types above? Each variant we created (like `Home_Model` and `Home_Msg`) are actually functions with signatures like this:
|
||||
|
||||
```elm
|
||||
Home_Model : Home.Model -> Model
|
||||
Home_Msg : Home.Msg -> Msg
|
||||
|
||||
AboutUs_Model : AboutUs.Model -> Model
|
||||
AboutUs_Msg : AboutUs.Msg -> Msg
|
||||
|
||||
Posts_Model : Posts.Model -> Model
|
||||
Posts_Msg : Posts.Msg -> Msg
|
||||
|
||||
-- ... same for Post, NotFound
|
||||
```
|
||||
|
||||
What that means is if I call `Home_Model` with a `Home.Model`, I'll get a `Model` back. The same goes for giving `About_Msg` an `AboutUs.Msg`!
|
||||
|
||||
All these varaiants are functions that tell each page how they can upgrade to the shared `Model` and `Msg` types we just defined.
|
||||
|
||||
With `elm-spa`, we want to "upgrade our pages" using these variants!
|
||||
|
||||
```elm
|
||||
type alias Upgraded pageFlags pageModel pageMsg =
|
||||
{ init :
|
||||
pageFlags
|
||||
-> Global.Model
|
||||
-> ( Model, Cmd Msg, Cmd Global.Msg )
|
||||
, update :
|
||||
pageMsg
|
||||
-> pageModel
|
||||
-> Global.Model
|
||||
-> ( Model, Cmd Msg, Cmd Global.Msg )
|
||||
, bundle :
|
||||
pageModel
|
||||
-> { view : Document Msg
|
||||
, subscriptions : Sub Msg
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Our goal now is to create an `Upgraded` for each page in our application. An "upgraded page" has all the information we need to return the correct values, no matter which page we are using.
|
||||
|
||||
It returns a record with three functions. Each of these functions take page-specific input and output the **same type of value**.
|
||||
|
||||
Returning the same type is what allows us to easily create the top-level `init`, `update`, `view`, and `subscriptions` functions.
|
||||
|
||||
Fortunately, if we provide `Page.upgrade` with those variant functions and a page, `elm-spa` handles the logic for us!
|
||||
|
||||
```elm
|
||||
pages :
|
||||
{ home : Upgraded Home.Flags Home.Model Home.Msg
|
||||
, aboutUs : Upgraded AboutUs.Flags AboutUs.Model AboutUs.Msg
|
||||
, posts : Upgraded Posts.Flags Posts.Model Posts.Msg
|
||||
, post : Upgraded Post.Flags Post.Model Post.Msg
|
||||
, notFound : Upgraded NotFound.Flags NotFound.Model NotFound.Msg
|
||||
}
|
||||
pages =
|
||||
{ home = Home.page |> Page.upgrade Home_Model Home_Msg
|
||||
, aboutUs = AboutUs.page |> Page.upgrade AboutUs_Model AboutUs_Msg
|
||||
, posts = Posts.page |> Page.upgrade Posts_Model Posts_Msg
|
||||
, post = Post.page |> Page.upgrade Post_Model Post_Msg
|
||||
, notFound = NotFound.page |> Page.upgrade NotFound_Model NotFound_Msg
|
||||
}
|
||||
```
|
||||
|
||||
It's okay if this doesn't make sense yet, let's look at how we use the upgraded `pages` values to create our top-level `Pages` functions:
|
||||
|
||||
### init
|
||||
|
||||
```elm
|
||||
init : Url -> Global.Model -> ( Model, Cmd Msg, Cmd Global.Msg )
|
||||
init url =
|
||||
case Route.fromUrl url of
|
||||
Route.Home ->
|
||||
pages.home.init ()
|
||||
|
||||
Route.AboutUs ->
|
||||
pages.aboutUs.init ()
|
||||
|
||||
Route.Posts ->
|
||||
pages.posts.init ()
|
||||
|
||||
Route.Post id ->
|
||||
pages.post.init id
|
||||
|
||||
Route.NotFound ->
|
||||
pages.notFound.init ()
|
||||
|
||||
```
|
||||
|
||||
### update
|
||||
|
||||
```elm
|
||||
update : Msg -> Model -> Global.Model -> ( Model, Cmd Msg, Cmd Global.Msg )
|
||||
update msg_ model_ =
|
||||
case ( msg_, model_ ) of
|
||||
( Home_Msg msg, Home_Model model ) ->
|
||||
pages.home.update msg model
|
||||
|
||||
( AboutUs_Msg msg, AboutUs_Model model ) ->
|
||||
pages.aboutUs.update msg model
|
||||
|
||||
( Posts_Msg msg, Posts_Model model ) ->
|
||||
pages.posts.update msg model
|
||||
|
||||
( Post_Msg msg, Post_Model model ) ->
|
||||
pages.post.update msg model
|
||||
|
||||
( NotFound_Msg msg, NotFound_Model model ) ->
|
||||
pages.notFound.update msg model
|
||||
|
||||
_ ->
|
||||
-- msg doesn't match model, no update
|
||||
( model_, Cmd.none, Cmd.none )
|
||||
```
|
||||
|
||||
### view + subscriptions
|
||||
|
||||
```elm
|
||||
bundle Model -> Global.Model -> { view : Document Msg, subscriptions : Sub Msg }
|
||||
bundle model_ =
|
||||
case model_ of
|
||||
Home_Model model ->
|
||||
pages.home.bundle model
|
||||
|
||||
AboutUs_Model model ->
|
||||
pages.aboutUs.bundle model
|
||||
|
||||
Posts_Model model ->
|
||||
pages.posts.bundle model
|
||||
|
||||
Post_Model model ->
|
||||
pages.post.bundle model
|
||||
|
||||
NotFound_Model model ->
|
||||
pages.notFound.bundle model
|
||||
|
||||
|
||||
view : Model -> Global.Model -> Document Msg
|
||||
view model =
|
||||
bundle model >> .view
|
||||
|
||||
|
||||
subscriptions : Model -> Global.Model -> Sub Msg
|
||||
subscriptions model =
|
||||
bundle model >> .subscriptions
|
||||
```
|
||||
|
||||
Each function provides page-specific flags, model, or msg values and returns the same `Model` and `Msg` types that the functions expect.
|
||||
|
||||
No need to manually upgrade these by hand.
|
||||
|
||||
|
||||
## src/Document.elm
|
||||
|
||||
```elm
|
||||
module Document exposing (Document, map)
|
||||
|
||||
import Html exposing (Html)
|
||||
|
||||
|
||||
type alias Document msg =
|
||||
{ title : String
|
||||
, body : List (Html msg)
|
||||
}
|
||||
|
||||
|
||||
map : (msg1 -> msg2) -> Document msg1 -> Document msg2
|
||||
map fn doc =
|
||||
{ title = doc.title
|
||||
, body = List.map (Html.map fn) doc.body
|
||||
}
|
||||
```
|
||||
|
||||
## src/Page.elm
|
||||
|
||||
The `ryannhg/elm-spa` package works with all different types of values, so it's useful to create a file at the top of the project to make type signatures less redundant and easier to read compiler errors.
|
||||
|
||||
Let's start with this:
|
||||
|
||||
```elm
|
||||
module Page exposing
|
||||
( Page
|
||||
, static
|
||||
, sandbox
|
||||
, element
|
||||
, component
|
||||
, upgrade
|
||||
)
|
||||
|
||||
import Document exposing (Document)
|
||||
import Global
|
||||
import Spa
|
||||
|
||||
|
||||
type alias Page flags model msg =
|
||||
{ init : Global.Model -> flags -> ( model, Cmd msg, Cmd Global.Msg )
|
||||
, update : Global.Msg -> msg -> model -> ( model, Cmd msg, Cmd Global.Msg )
|
||||
, bundle :
|
||||
{ view : Global.Model -> model -> Document msg
|
||||
, subscriptions : Global.Model -> model -> Sub msg
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static :
|
||||
{ view : Document msg
|
||||
}
|
||||
-> Page flags () msg
|
||||
static =
|
||||
Spa.static
|
||||
|
||||
|
||||
sandbox :
|
||||
{ init : model
|
||||
, update : msg -> model -> model
|
||||
, view : model -> Document msg
|
||||
}
|
||||
-> Page flags model msg
|
||||
sandbox =
|
||||
Spa.sandbox
|
||||
|
||||
|
||||
element :
|
||||
{ init : flags -> ( model, Cmd msg )
|
||||
, update : msg -> model -> ( model, Cmd msg )
|
||||
, view : model -> Document msg
|
||||
, subscriptions : model -> Sub msg
|
||||
}
|
||||
-> Page flags model msg
|
||||
element =
|
||||
Spa.element
|
||||
|
||||
|
||||
component :
|
||||
{ init : Global.Model -> flags -> ( model, Cmd msg, Cmd Global.Msg )
|
||||
, update : Global.Model -> msg -> model -> ( model, Cmd msg, Cmd Global.Msg )
|
||||
, view : Global.Model -> model -> Document msg
|
||||
, subscriptions : Global.Model -> model -> Sub msg
|
||||
}
|
||||
-> Page flags model msg
|
||||
component =
|
||||
Spa.component
|
||||
|
||||
|
||||
upgrade
|
||||
: (pageModel -> model)
|
||||
-> (pageMsg -> msg)
|
||||
-> Page flags model msg
|
||||
-> { init : flags -> Global.Model -> ( model, Cmd msg, Cmd Global.Msg )
|
||||
, update : msg -> model -> Global.Model -> ( model, Cmd msg, Cmd Global.Msg )
|
||||
, bundle : model -> Global.Model ->
|
||||
{ view : Document msg
|
||||
, subscriptions : Sub msg
|
||||
}
|
||||
}
|
||||
upgrade =
|
||||
Spa.upgrade
|
||||
```
|
||||
|
||||
|
||||
## src/Page/Home.elm
|
||||
|
||||
TODO
|
||||
|
||||
|
||||
## src/Page/AboutUs.elm
|
||||
|
||||
TODO
|
||||
|
||||
|
||||
## src/Page/Posts.elm
|
||||
|
||||
TODO
|
||||
|
||||
|
||||
## src/Page/Post.elm
|
||||
|
||||
TODO
|
||||
|
||||
|
||||
## src/Page/NotFound.elm
|
||||
|
||||
TODO
|
@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "elm-spa-example",
|
||||
"version": "4.1.0",
|
||||
"description": "my new elm-spa application",
|
||||
"main": "public/index.html",
|
||||
"scripts": {
|
||||
"start": "npm install && npm run build && npm run dev",
|
||||
"build": "npm run build:elm-spa && npm run build:elm",
|
||||
"build:elm-spa": "elm-spa build .",
|
||||
"build:elm": "elm make src/Main.elm --optimize --output public/dist/elm.js",
|
||||
"dev": "concurrently --raw --kill-others \"npm run dev:elm-spa\" \"npm run dev:elm\"",
|
||||
"dev:elm-spa": "chokidar src/Pages -c \"npm run build:elm-spa\"",
|
||||
"dev:elm": "elm-live src/Main.elm -u -d public -- --debug --output public/dist/elm.js"
|
||||
},
|
||||
"keywords": [
|
||||
"elm",
|
||||
"spa"
|
||||
],
|
||||
"author": "Ryan Haskell-Glatz",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"chokidar-cli": "2.1.0",
|
||||
"concurrently": "5.0.0",
|
||||
"elm": "0.19.1-3",
|
||||
"elm-live": "4.0.2",
|
||||
"elm-spa": "4.1.0"
|
||||
}
|
||||
}
|
@ -1,32 +0,0 @@
|
||||
.fixed--full {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.absolute--full {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.absolute--center {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.bg--overlay {
|
||||
background: rgba(0,0,0,0.25);
|
||||
}
|
||||
.min-width--480 {
|
||||
min-width: 320px;
|
||||
}
|
||||
.bg--white {
|
||||
background: white;
|
||||
}
|
@ -1,172 +0,0 @@
|
||||
module Components exposing
|
||||
( footer
|
||||
, layout
|
||||
, navbar
|
||||
)
|
||||
|
||||
import Browser exposing (Document)
|
||||
import Data.Modal as Modal exposing (Modal)
|
||||
import Data.SignInForm exposing (SignInForm)
|
||||
import Data.User as User exposing (User)
|
||||
import Html exposing (..)
|
||||
import Html.Attributes as Attr exposing (class, href)
|
||||
import Html.Events as Events
|
||||
import Generated.Route as Route
|
||||
|
||||
|
||||
|
||||
-- LAYOUT
|
||||
|
||||
|
||||
layout :
|
||||
{ page : Document msg
|
||||
, global :
|
||||
{ global
|
||||
| modal : Maybe Modal
|
||||
, user : Maybe User
|
||||
}
|
||||
, actions :
|
||||
{ onSignOut : msg
|
||||
, openSignInModal : msg
|
||||
, closeModal : msg
|
||||
, attemptSignIn : msg
|
||||
, onSignInEmailInput : String -> msg
|
||||
, onSignInPasswordInput : String -> msg
|
||||
}
|
||||
}
|
||||
-> Document msg
|
||||
layout { page, actions, global } =
|
||||
{ title = page.title
|
||||
, body =
|
||||
[ div [ class "container pad--medium column spacing--large h--fill" ]
|
||||
[ navbar { user = global.user, actions = actions }
|
||||
, div [ class "column spacing--large", Attr.style "flex" "1 0 auto" ] page.body
|
||||
, footer
|
||||
, global.modal
|
||||
|> Maybe.map (viewModal actions)
|
||||
|> Maybe.withDefault (text "")
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
|
||||
-- NAVBAR
|
||||
|
||||
|
||||
navbar :
|
||||
{ user : Maybe User
|
||||
, actions : { actions | openSignInModal : msg, onSignOut : msg }
|
||||
}
|
||||
-> Html msg
|
||||
navbar ({ actions } as options) =
|
||||
header [ class "container" ]
|
||||
[ div [ class "row spacing--between center-y" ]
|
||||
[ a [ class "link font--h5 font--bold", href "/" ] [ text "home" ]
|
||||
, div [ class "row spacing--medium center-y" ]
|
||||
[ a [ class "link", href "/about" ] [ text "about" ]
|
||||
, a [ class "link", href "/posts" ] [ text "posts" ]
|
||||
, case options.user of
|
||||
Just user ->
|
||||
a [ class "link", href (Route.toHref Route.Profile) ] [ text "profile" ]
|
||||
|
||||
Nothing ->
|
||||
button [ class "button", Events.onClick actions.openSignInModal ] [ text "sign in" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
|
||||
-- FOOTER
|
||||
|
||||
|
||||
footer : Html msg
|
||||
footer =
|
||||
Html.footer [ class "container py--medium" ]
|
||||
[ text "built with elm, 2020"
|
||||
]
|
||||
|
||||
|
||||
|
||||
-- MODAL
|
||||
|
||||
|
||||
viewModal :
|
||||
{ actions
|
||||
| closeModal : msg
|
||||
, attemptSignIn : msg
|
||||
, onSignInEmailInput : String -> msg
|
||||
, onSignInPasswordInput : String -> msg
|
||||
}
|
||||
-> Modal
|
||||
-> Html msg
|
||||
viewModal actions modal_ =
|
||||
case modal_ of
|
||||
Modal.SignInModal { email, password } ->
|
||||
modal
|
||||
{ title = "Sign in"
|
||||
, body =
|
||||
form [ class "column spacing--medium", Events.onSubmit actions.attemptSignIn ]
|
||||
[ emailField
|
||||
{ label = "Email"
|
||||
, value = email
|
||||
, onInput = actions.onSignInEmailInput
|
||||
}
|
||||
, passwordField
|
||||
{ label = "Password"
|
||||
, value = password
|
||||
, onInput = actions.onSignInPasswordInput
|
||||
}
|
||||
, button [ class "button" ] [ text "Sign in" ]
|
||||
]
|
||||
, actions = actions
|
||||
}
|
||||
|
||||
|
||||
modal :
|
||||
{ title : String
|
||||
, body : Html msg
|
||||
, actions : { actions | closeModal : msg }
|
||||
}
|
||||
-> Html msg
|
||||
modal ({ actions } as options) =
|
||||
div [ class "fixed--full" ]
|
||||
[ div [ class "absolute--full bg--overlay", Events.onClick actions.closeModal ] []
|
||||
, div [ class "column spacing--large pad--large absolute--center min-width--480 bg--white" ]
|
||||
[ div [ class "row spacing--between center-y" ]
|
||||
[ h3 [ class "font--h3" ] [ text options.title ]
|
||||
, button [ class "modal__close", Events.onClick actions.closeModal ] [ text "✖️" ]
|
||||
]
|
||||
, options.body
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
|
||||
-- FORMS
|
||||
|
||||
|
||||
inputField :
|
||||
String
|
||||
-> { label : String, value : String, onInput : String -> msg }
|
||||
-> Html msg
|
||||
inputField type_ options =
|
||||
label [ class "column spacing--small" ]
|
||||
[ span [] [ text options.label ]
|
||||
, input [ Attr.type_ type_, Attr.value options.value, Events.onInput options.onInput ] []
|
||||
]
|
||||
|
||||
|
||||
emailField :
|
||||
{ label : String, value : String, onInput : String -> msg }
|
||||
-> Html msg
|
||||
emailField =
|
||||
inputField "email"
|
||||
|
||||
|
||||
passwordField :
|
||||
{ label : String, value : String, onInput : String -> msg }
|
||||
-> Html msg
|
||||
passwordField =
|
||||
inputField "password"
|
@ -1,28 +0,0 @@
|
||||
module Data.Modal exposing
|
||||
( Modal(..)
|
||||
, signInForm
|
||||
, updateSignInForm
|
||||
)
|
||||
|
||||
import Data.SignInForm exposing (SignInForm)
|
||||
|
||||
|
||||
type Modal
|
||||
= SignInModal SignInForm
|
||||
|
||||
|
||||
signInForm : Modal -> Maybe SignInForm
|
||||
signInForm modal =
|
||||
case modal of
|
||||
SignInModal form ->
|
||||
Just form
|
||||
|
||||
|
||||
updateSignInForm :
|
||||
(SignInForm -> SignInForm)
|
||||
-> Modal
|
||||
-> Modal
|
||||
updateSignInForm fn modal =
|
||||
case modal of
|
||||
SignInModal form ->
|
||||
SignInModal (fn form)
|
@ -1,35 +0,0 @@
|
||||
module Data.SignInForm exposing
|
||||
( Field(..)
|
||||
, SignInForm
|
||||
, empty
|
||||
, updateEmail
|
||||
, updatePassword
|
||||
)
|
||||
|
||||
|
||||
type alias SignInForm =
|
||||
{ email : String
|
||||
, password : String
|
||||
}
|
||||
|
||||
|
||||
type Field
|
||||
= Email
|
||||
| Password
|
||||
|
||||
|
||||
empty : SignInForm
|
||||
empty =
|
||||
{ email = ""
|
||||
, password = ""
|
||||
}
|
||||
|
||||
|
||||
updateEmail : String -> SignInForm -> SignInForm
|
||||
updateEmail email form =
|
||||
{ form | email = email }
|
||||
|
||||
|
||||
updatePassword : String -> SignInForm -> SignInForm
|
||||
updatePassword password form =
|
||||
{ form | password = password }
|
@ -1,41 +0,0 @@
|
||||
module Data.Tab exposing
|
||||
( Tab
|
||||
, ourMission
|
||||
, ourTeam
|
||||
, ourValues
|
||||
, toString
|
||||
)
|
||||
|
||||
|
||||
type Tab
|
||||
= OurTeam
|
||||
| OurValues
|
||||
| OurMission
|
||||
|
||||
|
||||
ourTeam : Tab
|
||||
ourTeam =
|
||||
OurTeam
|
||||
|
||||
|
||||
ourValues : Tab
|
||||
ourValues =
|
||||
OurValues
|
||||
|
||||
|
||||
ourMission : Tab
|
||||
ourMission =
|
||||
OurMission
|
||||
|
||||
|
||||
toString : Tab -> String
|
||||
toString tab =
|
||||
case tab of
|
||||
OurTeam ->
|
||||
"Our Team"
|
||||
|
||||
OurValues ->
|
||||
"Our Values"
|
||||
|
||||
OurMission ->
|
||||
"Our Mission"
|
@ -1,16 +0,0 @@
|
||||
module Data.User exposing (User, fullname)
|
||||
|
||||
|
||||
type alias User =
|
||||
{ avatar : String
|
||||
, name :
|
||||
{ first : String
|
||||
, last : String
|
||||
}
|
||||
, email : String
|
||||
}
|
||||
|
||||
|
||||
fullname : User -> String
|
||||
fullname { name } =
|
||||
name.first ++ " " ++ name.last
|
@ -1,181 +0,0 @@
|
||||
module Global exposing
|
||||
( Flags
|
||||
, Model
|
||||
, Msg
|
||||
, init
|
||||
, openSignInModal
|
||||
, signOut
|
||||
, subscriptions
|
||||
, update
|
||||
, view
|
||||
)
|
||||
|
||||
import Browser exposing (Document)
|
||||
import Browser.Navigation as Nav
|
||||
import Components
|
||||
import Data.Modal as Modal exposing (Modal)
|
||||
import Data.SignInForm as SignInForm exposing (SignInForm)
|
||||
import Data.User exposing (User)
|
||||
import Task
|
||||
import Url exposing (Url)
|
||||
|
||||
|
||||
|
||||
-- INIT
|
||||
|
||||
|
||||
type alias Flags =
|
||||
()
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ flags : Flags
|
||||
, url : Url
|
||||
, key : Nav.Key
|
||||
, user : Maybe User
|
||||
, modal : Maybe Modal
|
||||
}
|
||||
|
||||
|
||||
init : Flags -> Url -> Nav.Key -> ( Model, Cmd Msg )
|
||||
init flags url key =
|
||||
( Model
|
||||
flags
|
||||
url
|
||||
key
|
||||
Nothing
|
||||
Nothing
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- UPDATE
|
||||
|
||||
|
||||
type Msg
|
||||
= AttemptSignIn
|
||||
| SignOut
|
||||
| UpdateSignInForm SignInForm.Field String
|
||||
| OpenModal Modal
|
||||
| CloseModal
|
||||
|
||||
|
||||
openSignInModal : Cmd Msg
|
||||
openSignInModal =
|
||||
send (OpenModal (Modal.SignInModal SignInForm.empty))
|
||||
|
||||
|
||||
signOut : Cmd Msg
|
||||
signOut =
|
||||
send SignOut
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
-- SIGN IN
|
||||
AttemptSignIn ->
|
||||
model.modal
|
||||
|> Maybe.andThen Modal.signInForm
|
||||
|> Maybe.map (attemptSignIn model)
|
||||
|> Maybe.withDefault ( model, Cmd.none )
|
||||
|
||||
SignOut ->
|
||||
( { model | user = Nothing }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
UpdateSignInForm field value ->
|
||||
let
|
||||
updateFieldWith : String -> SignInForm -> SignInForm
|
||||
updateFieldWith =
|
||||
case field of
|
||||
SignInForm.Email ->
|
||||
SignInForm.updateEmail
|
||||
|
||||
SignInForm.Password ->
|
||||
SignInForm.updatePassword
|
||||
in
|
||||
( model.modal
|
||||
|> Maybe.map (Modal.updateSignInForm (updateFieldWith value))
|
||||
|> (\modal -> { model | modal = modal })
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
-- MODAL
|
||||
OpenModal modal ->
|
||||
( { model | modal = Just modal }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
CloseModal ->
|
||||
( { model | modal = Nothing }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
|
||||
attemptSignIn : Model -> SignInForm -> ( Model, Cmd Msg )
|
||||
attemptSignIn model form =
|
||||
if form.email == "ryan.nhg@gmail.com" && form.password == "password" then
|
||||
( { model
|
||||
| user =
|
||||
Just
|
||||
(User
|
||||
"https://avatars2.githubusercontent.com/u/6187256?s=128&v=4"
|
||||
{ first = "Ryan", last = "Haskell-Glatz" }
|
||||
form.email
|
||||
)
|
||||
, modal = Nothing
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
else
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
|
||||
-- SUBSCRIPTIONS
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
Sub.none
|
||||
|
||||
|
||||
|
||||
-- VIEW
|
||||
|
||||
|
||||
view :
|
||||
{ page : Document msg
|
||||
, global : Model
|
||||
, toMsg : Msg -> msg
|
||||
}
|
||||
-> Document msg
|
||||
view { page, global, toMsg } =
|
||||
let
|
||||
actions =
|
||||
{ onSignOut = toMsg <| SignOut
|
||||
, openSignInModal = toMsg <| OpenModal (Modal.SignInModal SignInForm.empty)
|
||||
, closeModal = toMsg <| CloseModal
|
||||
, attemptSignIn = toMsg <| AttemptSignIn
|
||||
, onSignInEmailInput = toMsg << UpdateSignInForm SignInForm.Email
|
||||
, onSignInPasswordInput = toMsg << UpdateSignInForm SignInForm.Password
|
||||
}
|
||||
in
|
||||
Components.layout
|
||||
{ page = page
|
||||
, global = global
|
||||
, actions = actions
|
||||
}
|
||||
|
||||
|
||||
|
||||
-- UTILS
|
||||
|
||||
|
||||
send : msg -> Cmd msg
|
||||
send msg =
|
||||
Task.succeed msg |> Task.perform identity
|
@ -1,141 +0,0 @@
|
||||
module Main exposing (main)
|
||||
|
||||
import Browser exposing (Document)
|
||||
import Browser.Navigation as Nav exposing (Key)
|
||||
import Global
|
||||
import Html
|
||||
import Generated.Pages as Pages
|
||||
import Generated.Route as Route exposing (Route)
|
||||
import Url exposing (Url)
|
||||
|
||||
|
||||
main : Program Flags Model Msg
|
||||
main =
|
||||
Browser.application
|
||||
{ init = init
|
||||
, view = view
|
||||
, update = update
|
||||
, subscriptions = subscriptions
|
||||
, onUrlRequest = LinkClicked
|
||||
, onUrlChange = UrlChanged
|
||||
}
|
||||
|
||||
|
||||
|
||||
-- INIT
|
||||
|
||||
|
||||
type alias Flags =
|
||||
()
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ key : Key
|
||||
, url : Url
|
||||
, global : Global.Model
|
||||
, page : Pages.Model
|
||||
}
|
||||
|
||||
|
||||
init : Flags -> Url -> Key -> ( Model, Cmd Msg )
|
||||
init flags url key =
|
||||
let
|
||||
( global, globalCmd ) =
|
||||
Global.init flags url key
|
||||
|
||||
( page, pageCmd, pageGlobalCmd ) =
|
||||
Pages.init (fromUrl url) global
|
||||
in
|
||||
( Model key url global page
|
||||
, Cmd.batch
|
||||
[ Cmd.map Global globalCmd
|
||||
, Cmd.map Global pageGlobalCmd
|
||||
, Cmd.map Page pageCmd
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
type Msg
|
||||
= LinkClicked Browser.UrlRequest
|
||||
| UrlChanged Url
|
||||
| Global Global.Msg
|
||||
| Page Pages.Msg
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
LinkClicked (Browser.Internal url) ->
|
||||
( model, Nav.pushUrl model.key (Url.toString url) )
|
||||
|
||||
LinkClicked (Browser.External href) ->
|
||||
( model, Nav.load href )
|
||||
|
||||
UrlChanged url ->
|
||||
let
|
||||
( page, pageCmd, globalCmd ) =
|
||||
Pages.init (fromUrl url) model.global
|
||||
in
|
||||
( { model | url = url, page = page }
|
||||
, Cmd.batch
|
||||
[ Cmd.map Page pageCmd
|
||||
, Cmd.map Global globalCmd
|
||||
]
|
||||
)
|
||||
|
||||
Global globalMsg ->
|
||||
let
|
||||
( global, globalCmd ) =
|
||||
Global.update globalMsg model.global
|
||||
in
|
||||
( { model | global = global }
|
||||
, Cmd.map Global globalCmd
|
||||
)
|
||||
|
||||
Page pageMsg ->
|
||||
let
|
||||
( page, pageCmd, globalCmd ) =
|
||||
Pages.update pageMsg model.page model.global
|
||||
in
|
||||
( { model | page = page }
|
||||
, Cmd.batch
|
||||
[ Cmd.map Page pageCmd
|
||||
, Cmd.map Global globalCmd
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
Sub.batch
|
||||
[ model.global
|
||||
|> Global.subscriptions
|
||||
|> Sub.map Global
|
||||
, model.page
|
||||
|> (\page -> Pages.subscriptions page model.global)
|
||||
|> Sub.map Page
|
||||
]
|
||||
|
||||
|
||||
view : Model -> Browser.Document Msg
|
||||
view model =
|
||||
let
|
||||
documentMap :
|
||||
(msg1 -> msg2)
|
||||
-> Document msg1
|
||||
-> Document msg2
|
||||
documentMap fn doc =
|
||||
{ title = doc.title
|
||||
, body = List.map (Html.map fn) doc.body
|
||||
}
|
||||
in
|
||||
Global.view
|
||||
{ page = Pages.view model.page model.global |> documentMap Page
|
||||
, global = model.global
|
||||
, toMsg = Global
|
||||
}
|
||||
|
||||
|
||||
fromUrl : Url -> Route
|
||||
fromUrl =
|
||||
Route.fromUrl >> Maybe.withDefault Route.NotFound
|
@ -1,81 +0,0 @@
|
||||
module Page exposing
|
||||
( Page, Document, Bundle
|
||||
, upgrade
|
||||
, static, sandbox, element, component
|
||||
)
|
||||
|
||||
{-|
|
||||
|
||||
@docs Page, Document, Bundle
|
||||
|
||||
@docs upgrade
|
||||
|
||||
@docs static, sandbox, element, component
|
||||
|
||||
-}
|
||||
|
||||
import Browser
|
||||
import Global
|
||||
import Spa
|
||||
|
||||
|
||||
type alias Document msg =
|
||||
Browser.Document msg
|
||||
|
||||
|
||||
type alias Page flags model msg =
|
||||
Spa.Page flags model msg Global.Model Global.Msg
|
||||
|
||||
|
||||
type alias Bundle msg =
|
||||
Spa.Bundle msg
|
||||
|
||||
|
||||
upgrade :
|
||||
(pageModel -> model)
|
||||
-> (pageMsg -> msg)
|
||||
-> Page pageFlags pageModel pageMsg
|
||||
->
|
||||
{ init : pageFlags -> Global.Model -> ( model, Cmd msg, Cmd Global.Msg )
|
||||
, update : pageMsg -> pageModel -> Global.Model -> ( model, Cmd msg, Cmd Global.Msg )
|
||||
, bundle : pageModel -> Global.Model -> Bundle msg
|
||||
}
|
||||
upgrade =
|
||||
Spa.upgrade
|
||||
|
||||
|
||||
static : { view : Document msg } -> Page flags () msg
|
||||
static =
|
||||
Spa.static
|
||||
|
||||
|
||||
sandbox :
|
||||
{ init : model
|
||||
, update : msg -> model -> model
|
||||
, view : model -> Document msg
|
||||
}
|
||||
-> Page flags model msg
|
||||
sandbox =
|
||||
Spa.sandbox
|
||||
|
||||
|
||||
element :
|
||||
{ init : flags -> ( model, Cmd msg )
|
||||
, update : msg -> model -> ( model, Cmd msg )
|
||||
, subscriptions : model -> Sub msg
|
||||
, view : model -> Document msg
|
||||
}
|
||||
-> Page flags model msg
|
||||
element =
|
||||
Spa.element
|
||||
|
||||
|
||||
component :
|
||||
{ init : Global.Model -> flags -> ( model, Cmd msg, Cmd Global.Msg )
|
||||
, update : Global.Model -> msg -> model -> ( model, Cmd msg, Cmd Global.Msg )
|
||||
, subscriptions : Global.Model -> model -> Sub msg
|
||||
, view : Global.Model -> model -> Document msg
|
||||
}
|
||||
-> Page flags model msg
|
||||
component =
|
||||
Spa.component
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user