├── .gitignore
├── .travis.yml
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── Cargo.toml
├── LICENSE
├── README.md
├── Vagrantfile
├── app
├── brunch-config.js
├── elm
│ ├── Data
│ │ ├── Comment.elm
│ │ ├── Init.elm
│ │ └── User.elm
│ ├── Main.elm
│ ├── Markdown.elm
│ ├── Models.elm
│ ├── Msg.elm
│ ├── Native
│ │ └── Markdown.js
│ ├── Ports.elm
│ ├── Request
│ │ ├── Comment.elm
│ │ └── Init.elm
│ ├── Style.elm
│ ├── Stylesheets.elm
│ ├── Time
│ │ └── DateTime
│ │ │ └── Distance.elm
│ ├── Update.elm
│ ├── Util.elm
│ ├── View.elm
│ ├── elm-package.json
│ └── tests
│ │ ├── Helpers
│ │ └── Dates.elm
│ │ ├── Tests.elm
│ │ └── elm-package.json
├── js
│ └── oration.js
├── package.json
└── static
│ ├── favicon.ico
│ ├── index.html
│ ├── post-1.html
│ └── post-2.html
├── logo
├── logo_b.svg
├── logo_w.svg
└── logo_wbl.svg
├── migrations
├── .gitkeep
└── 20170719094701_create_oration
│ ├── down.sql
│ └── up.sql
├── oration.yaml
├── src
├── config.rs
├── data.rs
├── db.rs
├── errors.rs
├── main.rs
├── models
│ ├── comments
│ │ └── mod.rs
│ ├── mod.rs
│ ├── preferences
│ │ └── mod.rs
│ └── threads
│ │ └── mod.rs
├── notify.rs
├── schema.rs
├── static_files.rs
└── tests.rs
└── staging
├── config
├── nginx.conf
├── nginx.vhost.conf
└── oration.service
├── prepare.yml
├── services.yml
└── staging.yml
/.gitignore:
--------------------------------------------------------------------------------
1 | target
2 | public
3 | **/*.rs.bk
4 | *~
5 | app/node_modules
6 | app/elm/elm-stuff
7 | app/elm/tests/elm-stuff
8 | app/js/main.js
9 | app/css/main.css
10 | app/css/oration.css
11 | app/package-lock.json
12 | Cargo.lock
13 | *.db
14 | *.retry
15 | .env
16 | .vagrant
17 | staging/deploy
18 | releases
19 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | matrix:
2 | include:
3 | - language: rust
4 | rust: nightly
5 | cache: cargo
6 | sudo: true
7 | dist: trusty
8 | os: linux
9 | addons:
10 | apt:
11 | packages:
12 | - sqlite3
13 | before_script:
14 | - ( cargo install diesel_cli --no-default-features --features sqlite || true )
15 | - export PATH=$PATH:~/.cargo/bin
16 | - echo "DATABASE_URL=oration.db" > .env
17 | - diesel migration run
18 | - cargo update
19 | script:
20 | - cargo build
21 | - cargo test
22 | after_success:
23 | # Upload docs
24 | - |
25 | if [[ "$TRAVIS_PULL_REQUEST" = "false" && "$TRAVIS_BRANCH" == "master" ]]; then
26 | cargo rustdoc -- --document-private-items &&
27 | cp logo/logo_wbl.svg target/doc/ &&
28 | echo "" > target/doc/index.html &&
29 | git clone https://github.com/davisp/ghp-import.git &&
30 | ./ghp-import/ghp_import.py -n -p -f -m "Documentation upload" -r https://"$GH_TOKEN"@github.com/"$TRAVIS_REPO_SLUG.git" target/doc &&
31 | echo "Uploaded documentation"
32 | fi
33 | # Coverage report
34 | - |
35 | `RUSTFLAGS="--cfg procmacro2_semver_exempt" cargo install cargo-tarpaulin`
36 | bash <(curl https://raw.githubusercontent.com/xd009642/tarpaulin/master/travis-install.sh)
37 | cargo tarpaulin --out Xml
38 | bash <(curl -s https://codecov.io/bash)
39 | echo "Uploaded code coverage"
40 | - language: node_js
41 | node_js: node
42 | os: linux
43 | cache:
44 | directories:
45 | - sysconfcpus
46 | - node_modules
47 | - app/elm/elm-stuff/build-artifacts
48 | - app/elm/tests/elm-stuff/build-artifacts
49 | before_install:
50 | - | # epic build time improvement - see https://github.com/elm-lang/elm-compiler/issues/1473#issuecomment-245704142
51 | if [ ! -d sysconfcpus/bin ]; then
52 | git clone https://github.com/obmarg/libsysconfcpus.git;
53 | cd libsysconfcpus;
54 | ./configure --prefix=$TRAVIS_BUILD_DIR/sysconfcpus;
55 | make && make install;
56 | cd ..;
57 | fi
58 | install:
59 | - npm install -g elm@0.18 elm-test elm-coverage elm-css
60 | - mv $(npm config get prefix)/bin/elm-make $(npm config get prefix)/bin/elm-make-old
61 | - echo -e "#\!/bin/bash\\n\\necho \"Running elm-make with sysconfcpus -n 2\"\\n\\n$TRAVIS_BUILD_DIR/sysconfcpus/bin/sysconfcpus -n 2 elm-make-old \"\$@\"" > $(npm config get prefix)/bin/elm-make
62 | - chmod +x $(npm config get prefix)/bin/elm-make
63 | script:
64 | - cd app/elm
65 | - elm-make --yes
66 | - elm-css Stylesheets.elm
67 | - elm-coverage .
68 | - bash <(curl -s https://codecov.io/bash)
69 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
6 |
7 | ## Our Standards
8 |
9 | Examples of behavior that contributes to creating a positive environment include:
10 |
11 | * Using welcoming and inclusive language
12 | * Being respectful of differing viewpoints and experiences
13 | * Gracefully accepting constructive criticism
14 | * Focusing on what is best for the community
15 | * Showing empathy towards other community members
16 |
17 | Examples of unacceptable behavior by participants include:
18 |
19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances
20 | * Trolling, insulting/derogatory comments, and personal or political attacks
21 | * Public or private harassment
22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission
23 | * Other conduct which could reasonably be considered inappropriate in a professional setting
24 |
25 | ## Our Responsibilities
26 |
27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
28 |
29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
30 |
31 | ## Scope
32 |
33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
34 |
35 | ## Enforcement
36 |
37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at tim@neophilus.net. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
38 |
39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
40 |
41 | ## Attribution
42 |
43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
44 |
45 | [homepage]: http://contributor-covenant.org
46 | [version]: http://contributor-covenant.org/version/1/4/
47 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Introduction
2 |
3 | Thank you for considering contributing to Oration.
4 |
5 | Oration is an open source project and we love to receive contributions from our community — you! There are many ways to contribute, from improving the documentation, submitting bug reports and feature requests or writing code which can be incorporated into Oration itself.
6 | As Oration is still pre 0.1, there are no channels other than the issue tracker setup for communication. If you have a support question, please file an issue with the tracker.
7 | This may change as Oration grows.
8 |
9 | # Responsibilities
10 |
11 | * Create issues for any major changes and enhancements that you wish to make. Discuss things transparently and get community feedback.
12 | * Keep feature versions as small as possible, preferably one new feature per version.
13 | * Be welcoming to newcomers and encourage diverse new contributors from all backgrounds. See our [Code of Conduct](CODE_OF_CONDUCT.md) and the [Rust Community Code of Conduct](https://www.rust-lang.org/en-US/conduct.html).
14 | * External services such as Gravatar need to be considered by the community before inclusion. Even then, they should be completely optional.
15 |
16 | # Your First Contribution
17 |
18 | Unsure where to begin contributing to Oration? You can start by looking through the tracker and assign yourself to any issue that currently do not have an assignee.
19 | Issues labeled Help wanted in particular.
20 |
21 | Working on your first Pull Request? You can learn how from this *free* series, [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github).
22 | If a maintainer asks you to "rebase" your PR, they're saying that a lot of code has changed, and that you need to update your branch so it's easier to merge.
23 |
24 | # How to report a bug
25 |
26 | If you find a security vulnerability, do NOT open an issue. Email tim@neophilus.net instead.
27 |
28 | In order to determine whether you are dealing with a security issue, ask yourself these two questions:
29 | * Can I access something that's not mine, or something I shouldn't have access to?
30 | * Can I disable something for other people?
31 |
32 | If the answer to either of those two questions are "yes", then you're probably dealing with a security issue. Note that even if you answer "no" to both questions, you may still be dealing with a security issue, so if you're unsure, just email.
33 |
34 | ----
35 |
36 | When filing an issue, make sure to answer/consider these five questions:
37 |
38 | 1. Have you updated to the latest rust nightly?
39 | 2. What operating system and processor architecture are you using?
40 | 3. What did you do?
41 | 4. What did you expect to see?
42 | 5. What did you see instead?
43 |
44 | # How to suggest a feature or enhancement
45 |
46 | Oration aims to be a fast, lightweight and secure. External resources such as Gravatar therefore remain a matter of contention.
47 | With that being said, if you find yourself wishing for a feature that doesn't exist in Oration, you are probably not alone.
48 | There are bound to be others out there with similar needs. Many of the features that Oration has today have been added because our users saw the need.
49 | Open an issue on our issues list on GitHub which describes the feature you would like to see, why you need it, and how it should work.
50 | The community will decide on its adoption and how to best implement it.
51 |
52 | # Code review process
53 |
54 | For the moment, Libbum will review your PR's whenever there is available time. Feel free to give him a prod if needed.
55 |
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "oration"
3 | version = "0.1.0"
4 | authors = ["Tim DuBois Here are a few posts that you might enjoy commenting on: Post 1 - x Comments Post 2 - x Comments
2 |
3 | A Rocket/Elm self hosted commenting system for static sites.
4 |
5 | Inspired by [Isso](https://posativ.org/isso/), which is a welcomed change from Disqus.
6 | However, the codebase is unmaintained and [security concerns](https://axiomatic.neophilus.net/posts/2017-04-16-from-disqus-to-isso.html) abound.
7 |
8 | *Oration* aims to be a fast, lightweight and secure platform for your comments. Nothing more, but importantly, nothing less.
9 |
10 | ---
11 |
12 | Oration is currently in an early stage of development, but [v0.1.1](https://github.com/Libbum/oration/releases/tag/v0.1.1) is usable now with minimal setup and a good deal of front facing features.
13 | Administration, porting from other commenting systems and a number of additional features are [planned](https://github.com/Libbum/oration/milestones) with a roadmap targeting a complete public release at v0.3.
14 |
15 | Contributions are welcome, please see our [guidelines](CONTRIBUTING.md) and [Code of Conduct](CODE_OF_CONDUCT.md).
16 |
17 | # Get it running now
18 |
19 | A static binary for the backend that runs on any Linux machine can be found in the [release tarball](https://github.com/Libbum/oration/releases/download/v0.1/oration-v0.1.tar.gz), along with a configuration file and minified `oration.{js,css}` files for you to put in your blog files.
20 |
21 | A staging virtual machine using [Vagrant](https://www.vagrantup.com/) and [Ansible](https://www.ansible.com/) is available if you wish to build a test machine direct from the source, although this will require a few more development tools to be installed on your system (like docker for instance).
22 | Please read the comments in [`staging/prepare.yml`](staging/prepare.yml) to setup the standalone build system.
23 | However, this staging setup shows you exactly how to put oration behind an [Nginx proxy](staging/config/nginx.vhost.conf) with hardened security headers, once you have it [running as a service](staging/config/oration.service).
24 |
25 | Before running the service, make sure an `.env` file that points to the location of an sqlite database initialised with [these commands](migrations/20170719094701_create_oration/up.sql), and the [configuration file](oration.yaml) with details specific for your machine both exist in the directory oration is located in.
26 |
27 | On the front end, it's simply a manner of uploading the css and js files to your public directory, and editing your blog posts to point to these assets.
28 | An example of this can be seen [here](app/static/post-1.html).
29 |
30 | More complete documentation is on the way.
31 |
32 | # Development Startup
33 |
34 | ```bash
35 | $ echo DATABASE_URL=oration.db > .env
36 | $ diesel migration run
37 | $ cd app/elm
38 | $ elm-package install
39 | $ cd ..
40 | $ brunch build
41 | $ cd ..
42 | $ cargo run
43 | ```
44 |
45 | for live reloading of `app` files:
46 |
47 | ```bash
48 | $ cd app
49 | $ npm run watch
50 | ```
51 |
52 | Until such time as I fix the build system, you'll also need to do some finicky stuff to get the style sheets building correctly.
53 |
54 | From the `app` directory:
55 | 1. `mkdir css`
56 | 2. `npm run watch`
57 | 3. Edit `elm/Stylesheets.elm` and save it.
58 |
59 | # Documentation
60 |
61 | Documentation of current backend methods can be viewed [here](https://libbum.github.io/oration/oration/index.html).
62 |
63 | # Options
64 |
65 | Code highlighting is done with [prism.js](http://prismjs.com/).
66 | The default syntax pack and a few extra markups are obtained via a CDN here, although you may wish to modify the allowable languages used on your blog.
67 | Replace the default pack with one customised from [here](http://prismjs.com/download.html) to achive this.
68 | The CDN isn't a bad idea however, and pulling multiple files the example here is all done over http v2, so it's pretty fast.
69 |
70 | In the same manner, you may change the theme of the syntax highlighting by choosing [another theme](https://github.com/PrismJS/prism/tree/gh-pages/themes).
71 | Oration uses the default in the example file.
72 |
73 | These changes should be in your own html files, an example can be seen in the bundled [index.html](app/static/index.html) header.
74 |
75 |
76 | ## License
77 | [](https://app.fossa.io/projects/git%2Bgithub.com%2FLibbum%2Foration?ref=badge_shield)
78 |
79 | [](https://app.fossa.io/projects/git%2Bgithub.com%2FLibbum%2Foration?ref=badge_large)
80 |
--------------------------------------------------------------------------------
/Vagrantfile:
--------------------------------------------------------------------------------
1 | # -*- mode: ruby -*-
2 | # vi: set ft=ruby :
3 |
4 | # You'll need the vagrant-triggers plugin for this file to function correctly:
5 | # $ vagrant plugin install vagrant-triggers
6 |
7 | Vagrant.configure("2") do |config|
8 | config.vm.box = "debian/jessie64"
9 |
10 | config.vm.network :forwarded_port, guest: 80, host: 8600
11 |
12 | config.vm.provision "ansible" do |ansible|
13 | ansible.playbook = "staging/staging.yml"
14 | ansible.limit = "all"
15 | ansible.verbose = "v"
16 | end
17 |
18 | config.vm.provision "ansible", run: "always" do |ansible|
19 | ansible.playbook = "staging/services.yml"
20 | ansible.limit = "all"
21 | ansible.verbose = "v"
22 | end
23 |
24 | config.vm.synced_folder "public", "/vagrant/public_html", create: true, type: "rsync"
25 | config.vm.synced_folder "staging/deploy", "/vagrant/app", create: true, type: "rsync"
26 | config.vm.synced_folder ".", "/vagrant", disabled: true
27 |
28 | config.vm.post_up_message = "The Oration staging service is now available at http://localhost:8600"
29 |
30 | config.trigger.before [:up, :resume, :reload] do
31 | info "Building files for staging environment"
32 | run "ansible-playbook -i 'localhost,' -c local staging/prepare.yml"
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/app/brunch-config.js:
--------------------------------------------------------------------------------
1 | exports.config = {
2 | files: {
3 | javascripts: {
4 | joinTo: "js/oration.js"
5 | },
6 | stylesheets: {
7 | joinTo: "css/oration.css"
8 | }
9 | },
10 | conventions: {
11 | // This option sets where we should place non-css and non-js assets in.
12 | // By default, we set this to "/assets/static". Files in this directory
13 | // will be copied to `paths.public`, which is set below to "../public".
14 | assets: /^(static)/,
15 | ignored: /elm-stuff/
16 | },
17 | // paths configuration
18 | paths: {
19 | // Dependencies and current project directories to watch
20 | watched: ["static", "js", "css", "elm"],
21 | // Where to compile files to
22 | public: "../public"
23 | },
24 | plugins: {
25 | babel: {
26 | ignore: [/main.js$/]
27 | },
28 | elmBrunch: {
29 | elmFolder: "elm",
30 | mainModules: ["Main.elm"],
31 | makeParameters: ["--warn","--debug"],
32 | outputFolder: "../js"
33 | },
34 | elmCss: {
35 | projectDir: "elm",
36 | sourcePath: "Stylesheets.elm",
37 | pattern: "Style.elm",
38 | outputDir: "../css"
39 | },
40 | cssnano: {
41 | preset: [
42 | 'default',
43 | {discardComments: {removeAll: true}}
44 | ]
45 | }
46 | },
47 | modules: {
48 | autoRequire: {
49 | "js/oration.js": ["js/oration"],
50 | "css/oration.css": ["css/oration"]
51 | }
52 | },
53 | overrides: {
54 | production: {
55 | plugins: {
56 | elmBrunch: {
57 | makeParameters: ["--warn"]
58 | }
59 | }
60 | }
61 | }
62 | };
63 |
--------------------------------------------------------------------------------
/app/elm/Data/Comment.elm:
--------------------------------------------------------------------------------
1 | module Data.Comment exposing (Comment, Edited, Inserted, Responses(Responses), count, decoder, delete, disableVote, dislike, editDecoder, encode, getText, insertDecoder, insertNew, like, readOnly, toggleVisible, update)
2 |
3 | import Json.Decode as Decode exposing (Decoder)
4 | import Json.Decode.Extra as DecodeExtra
5 | import Json.Decode.Pipeline exposing (decode, hardcoded, required)
6 | import Json.Encode as Encode exposing (Value)
7 | import Json.Encode.Extra as EncodeExtra
8 | import Maybe.Extra exposing ((?), isNothing, values)
9 | import Time.DateTime exposing (DateTime)
10 | import Util exposing ((=>))
11 |
12 |
13 | type alias Comment =
14 | { text : String
15 | , author : Maybe String
16 | , hash : String
17 | , created : DateTime
18 | , id : Int
19 | , votes : Int
20 | , children : Responses
21 | , visible : Bool
22 | , editable : Bool
23 | , votable : Bool
24 | }
25 |
26 |
27 | type Responses
28 | = Responses (List Comment)
29 |
30 |
31 | type alias Inserted =
32 | { id : Int
33 | , parent : Maybe Int
34 | , author : Maybe String
35 | }
36 |
37 |
38 | type alias Edited =
39 | { id : Int
40 | , author : Maybe String
41 | , hash : String
42 | , text : String
43 | }
44 |
45 |
46 |
47 | {- TOTAL COUNT -}
48 |
49 |
50 | count : List Comment -> Int
51 | count =
52 | foldl (\_ acc -> acc + 1) 0
53 |
54 |
55 |
56 | {- STRUCTURE UPDATES -}
57 |
58 |
59 | insertNew : Inserted -> ( String, String, DateTime, List Comment ) -> List Comment
60 | insertNew insert current =
61 | let
62 | ( commentText, hash, now, comments ) =
63 | current
64 |
65 | newComment =
66 | { text = commentText
67 | , author = insert.author
68 | , hash = hash
69 | , created = now
70 | , id = insert.id
71 | , votes = 0
72 | , children = Responses []
73 | , visible = True
74 | , editable = True
75 | , votable = False
76 | }
77 | in
78 | if isNothing insert.parent then
79 | comments ++ List.singleton newComment
80 | else
81 | List.map (\comment -> injectNew insert newComment comment) comments
82 |
83 |
84 | injectNew : Inserted -> Comment -> Comment -> Comment
85 | injectNew insert newComment comment =
86 | let
87 | children =
88 | if comment.id == insert.parent ? -1 then
89 | case comment.children of
90 | Responses responses ->
91 | Responses <| responses ++ List.singleton newComment
92 | else
93 | case comment.children of
94 | Responses responses ->
95 | Responses <| List.map (\response -> injectNew insert newComment response) responses
96 | in
97 | { comment | children = children }
98 |
99 |
100 | update : Edited -> List Comment -> List Comment
101 | update edit comments =
102 | List.map (\comment -> injectUpdates edit comment) comments
103 |
104 |
105 | injectUpdates : Edited -> Comment -> Comment
106 | injectUpdates edit comment =
107 | if edit.id == comment.id then
108 | { comment
109 | | text = edit.text
110 | , author = edit.author
111 | , hash = edit.hash
112 | , editable = True
113 | }
114 | else
115 | mapChildren edit comment injectUpdates
116 |
117 |
118 | toggleVisible : Int -> List Comment -> List Comment
119 | toggleVisible id comments =
120 | List.map (\comment -> switchVisible id comment) comments
121 |
122 |
123 | switchVisible : Int -> Comment -> Comment
124 | switchVisible id comment =
125 | if comment.id == id then
126 | { comment | visible = not comment.visible }
127 | else
128 | mapChildren id comment switchVisible
129 |
130 |
131 | delete : Int -> List Comment -> List Comment
132 | delete id comments =
133 | List.map (\comment -> filterComment id comment) comments
134 | |> values
135 |
136 |
137 | filterComment : Int -> Comment -> Maybe Comment
138 | filterComment id comment =
139 | if comment.id == id then
140 | let
141 | --Pure deletes only happen on comments with no children, so only filter if that's the case
142 | noChildren =
143 | case comment.children of
144 | Responses responses ->
145 | List.isEmpty responses
146 | in
147 | if noChildren then
148 | Nothing
149 | else
150 | --We must display a masked delete
151 | let
152 | children =
153 | case comment.children of
154 | Responses responses ->
155 | Responses <| values <| List.map (\response -> filterComment id response) responses
156 | in
157 | Just
158 | { comment
159 | | children = children
160 | , author = Nothing
161 | , hash = ""
162 | , text = ""
163 | , votes = 0
164 | , votable = False
165 | }
166 | else
167 | let
168 | children =
169 | case comment.children of
170 | Responses responses ->
171 | Responses <| values <| List.map (\response -> filterComment id response) responses
172 | in
173 | Just { comment | children = children }
174 |
175 |
176 | readOnly : Int -> List Comment -> List Comment
177 | readOnly id comments =
178 | List.map (\comment -> removeEditable id comment) comments
179 |
180 |
181 | removeEditable : Int -> Comment -> Comment
182 | removeEditable id comment =
183 | if comment.id == id then
184 | { comment | editable = False }
185 | else
186 | mapChildren id comment removeEditable
187 |
188 |
189 | like : Int -> List Comment -> List Comment
190 | like id comments =
191 | List.map (\comment -> voteComment ( id, True ) comment) comments
192 |
193 |
194 | dislike : Int -> List Comment -> List Comment
195 | dislike id comments =
196 | List.map (\comment -> voteComment ( id, False ) comment) comments
197 |
198 |
199 | voteComment : ( Int, Bool ) -> Comment -> Comment
200 | voteComment ( id, like ) comment =
201 | if comment.id == id then
202 | let
203 | count =
204 | case like of
205 | True ->
206 | comment.votes + 1
207 |
208 | False ->
209 | comment.votes - 1
210 | in
211 | { comment
212 | | votes = count
213 | , votable = False
214 | }
215 | else
216 | mapChildren ( id, like ) comment voteComment
217 |
218 |
219 | disableVote : Int -> List Comment -> List Comment
220 | disableVote id comments =
221 | List.map (\comment -> removeVotable id comment) comments
222 |
223 |
224 | removeVotable : Int -> Comment -> Comment
225 | removeVotable id comment =
226 | if comment.id == id then
227 | { comment | votable = False }
228 | else
229 | mapChildren id comment removeVotable
230 |
231 |
232 | mapChildren : a -> Comment -> (a -> Comment -> Comment) -> Comment
233 | mapChildren value comment operation =
234 | let
235 | children =
236 | case comment.children of
237 | Responses responses ->
238 | Responses <| List.map (\response -> operation value response) responses
239 | in
240 | { comment | children = children }
241 |
242 |
243 |
244 | {- INFORMATION GATHERING -}
245 |
246 |
247 | getText : Int -> List Comment -> String
248 | getText id comments =
249 | let
250 | --id is unique, so we will only find one comment that isn't empty,
251 | --we can take the head of the filtered list
252 | found =
253 | foldl (\y ys -> findText id y :: ys) [] comments
254 | |> List.filter (not << String.isEmpty)
255 | |> List.head
256 | in
257 | case found of
258 | Just text ->
259 | text
260 |
261 | Nothing ->
262 | ""
263 |
264 |
265 | findText : Int -> Comment -> String
266 | findText id comment =
267 | if comment.id == id then
268 | comment.text
269 | else
270 | ""
271 |
272 |
273 |
274 | {- RECURSIVE ABILITIES -}
275 |
276 |
277 | foldl : (Comment -> b -> b) -> b -> List Comment -> b
278 | foldl f =
279 | List.foldl
280 | (\c acc ->
281 | case c.children of
282 | Responses responses ->
283 | foldl f (f c acc) responses
284 | )
285 |
286 |
287 |
288 | {- SERIALIZATION -}
289 |
290 |
291 | decoder : Decoder Comment
292 | decoder =
293 | decode Comment
294 | |> required "text" Decode.string
295 | |> required "author" (Decode.nullable Decode.string)
296 | |> required "hash" Decode.string
297 | |> required "created" decodeDate
298 | |> required "id" Decode.int
299 | |> required "votes" Decode.int
300 | |> required "children" decodeResponses
301 | |> hardcoded True
302 | |> hardcoded False
303 | |> hardcoded True
304 |
305 |
306 | decodeResponses : Decoder Responses
307 | decodeResponses =
308 | Decode.map Responses (Decode.list (Decode.lazy (\_ -> decoder)))
309 |
310 |
311 | decodeDate : Decoder DateTime
312 | decodeDate =
313 | Decode.string
314 | |> Decode.andThen (Time.DateTime.fromISO8601 >> DecodeExtra.fromResult)
315 |
316 |
317 | encode : Comment -> Value
318 | encode comment =
319 | Encode.object
320 | [ "text" => Encode.string comment.text
321 | , "author" => EncodeExtra.maybe Encode.string comment.author
322 | , "hash" => Encode.string comment.hash
323 | ]
324 |
325 |
326 | insertDecoder : Decoder Inserted
327 | insertDecoder =
328 | decode Inserted
329 | |> required "id" Decode.int
330 | |> required "parent" (Decode.nullable Decode.int)
331 | |> required "author" (Decode.nullable Decode.string)
332 |
333 |
334 | editDecoder : Decoder Edited
335 | editDecoder =
336 | decode Edited
337 | |> required "id" Decode.int
338 | |> required "author" (Decode.nullable Decode.string)
339 | |> required "hash" Decode.string
340 | |> required "text" Decode.string
341 |
--------------------------------------------------------------------------------
/app/elm/Data/Init.elm:
--------------------------------------------------------------------------------
1 | module Data.Init exposing (Init, decoder)
2 |
3 | import Json.Decode as Decode exposing (Decoder)
4 | import Json.Decode.Pipeline exposing (decode, required)
5 |
6 |
7 | type alias Init =
8 | { userIp : Maybe String
9 | , blogAuthor : Maybe String
10 | , editTimeout : Float
11 | }
12 |
13 |
14 |
15 | -- SERIALIZATION --
16 |
17 |
18 | decoder : Decoder Init
19 | decoder =
20 | decode Init
21 | |> required "user_ip" (Decode.nullable Decode.string)
22 | |> required "blog_author" (Decode.nullable Decode.string)
23 | |> required "edit_timeout" Decode.float
24 |
--------------------------------------------------------------------------------
/app/elm/Data/User.elm:
--------------------------------------------------------------------------------
1 | module Data.User exposing (Identity, User, decoder, encode, getIdentity)
2 |
3 | import Crypto.Hash
4 | import Json.Decode as Decode exposing (Decoder)
5 | import Json.Decode.Pipeline exposing (decode, optional, required)
6 | import Json.Encode as Encode exposing (Value)
7 | import Json.Encode.Extra as EncodeExtra
8 | import Maybe.Extra exposing ((?), isNothing)
9 | import Util exposing ((=>))
10 |
11 |
12 | type alias User =
13 | { name : Maybe String
14 | , email : Maybe String
15 | , url : Maybe String
16 | , iphash : Maybe String
17 | , preview : Bool
18 | , identity : Identity
19 | }
20 |
21 |
22 | type alias Identity =
23 | String
24 |
25 |
26 |
27 | {- Hashes user information depending on available data -}
28 |
29 |
30 | getIdentity : User -> Identity
31 | getIdentity user =
32 | let
33 | data =
34 | [ user.name, user.email, user.url ]
35 |
36 | --I think Maybe.Extra.values could also be used here
37 | unwrapped =
38 | List.filterMap identity data
39 | in
40 | if List.all isNothing data then
41 | user.iphash ? ""
42 | else
43 | -- Join with b since it gives the authors' credentials a cool identicon
44 | Crypto.Hash.sha224 (String.join "b" unwrapped)
45 |
46 |
47 |
48 | -- SERIALIZATION --
49 |
50 |
51 | decoder : Decoder User
52 | decoder =
53 | decode User
54 | |> required "name" (Decode.nullable Decode.string)
55 | |> required "email" (Decode.nullable Decode.string)
56 | |> required "url" (Decode.nullable Decode.string)
57 | |> required "iphash" (Decode.nullable Decode.string)
58 | |> required "preview" Decode.bool
59 | |> optional "identity" Decode.string ""
60 |
61 |
62 | encode : User -> Value
63 | encode user =
64 | Encode.object
65 | [ "name" => EncodeExtra.maybe Encode.string user.name
66 | , "email" => EncodeExtra.maybe Encode.string user.email
67 | , "url" => EncodeExtra.maybe Encode.string user.url
68 | , "iphash" => EncodeExtra.maybe Encode.string user.iphash
69 | , "preview" => Encode.bool user.preview
70 | , "identity" => Encode.string user.identity
71 | ]
72 |
--------------------------------------------------------------------------------
/app/elm/Main.elm:
--------------------------------------------------------------------------------
1 | module Main exposing (main)
2 |
3 | import Http
4 | import Models exposing (Model, Status(Commenting))
5 | import Msg exposing (Msg)
6 | import Navigation
7 | import Request.Comment
8 | import Request.Init
9 | import Task
10 | import Time.DateTime exposing (dateTime, zero)
11 | import Update exposing (currentDate, subscriptions, update)
12 | import View exposing (view)
13 |
14 |
15 | main : Program Never Model Msg
16 | main =
17 | Navigation.program Msg.Post { init = init, view = view, update = update, subscriptions = subscriptions }
18 |
19 |
20 | init : Navigation.Location -> ( Model, Cmd Msg )
21 | init location =
22 | ( { comment = ""
23 | , parent = Nothing
24 | , user =
25 | { name = Nothing
26 | , email = Nothing
27 | , url = Nothing
28 | , preview = False
29 | , iphash = Nothing
30 | , identity = ""
31 | }
32 | , comments = []
33 | , count = 0
34 | , post = location
35 | , title = ""
36 | , debug = ""
37 | , now = dateTime zero
38 | , editTimeout = 120
39 | , blogAuthor = ""
40 | , status = Commenting
41 | }
42 | , initialise location
43 | )
44 |
45 |
46 | initialise : Navigation.Location -> Cmd Msg
47 | initialise location =
48 | let
49 | loadHashes =
50 | Request.Init.hashes
51 | |> Http.toTask
52 |
53 | loadComments =
54 | Request.Comment.comments location
55 | |> Http.toTask
56 | in
57 | Cmd.batch
58 | [ Task.attempt Msg.Hashes loadHashes
59 | , Task.attempt Msg.Comments loadComments
60 | , Task.perform Msg.NewDate currentDate
61 | ]
62 |
--------------------------------------------------------------------------------
/app/elm/Markdown.elm:
--------------------------------------------------------------------------------
1 | module Markdown
2 | exposing
3 | ( Options
4 | , defaultOptions
5 | , toHtml
6 | , toHtmlWith
7 | )
8 |
9 | {-| A library for markdown parsing. This is just an Elm API built on top of the
10 | [markdown-it](https://github.com/markdown-it/markdown-it) project which focuses on speed.
11 |
12 |
13 | # Parsing Markdown
14 |
15 | @docs toHtml
16 |
17 |
18 | # Parsing with Custom Options
19 |
20 | @docs Options, defaultOptions, toHtmlWith
21 |
22 | -}
23 |
24 | import Html exposing (Attribute, Html)
25 | import Native.Markdown
26 |
27 |
28 | {-| Turn a markdown string into an HTML element, using the `defaultOptions`.
29 |
30 | recipe : Html msg
31 | recipe =
32 | Markdown.toHtml [ class "recipe" ] """
33 |
34 | # Apple Pie Recipe
35 |
36 | First, invent the universe. Then bake an apple pie.
37 |
38 | """
39 |
40 | -}
41 | toHtml : List (Attribute msg) -> String -> Html msg
42 | toHtml attrs string =
43 | Native.Markdown.toHtml defaultOptions attrs string
44 |
45 |
46 | {-| Some parser options so you can tweak things for your particular case.
47 |
48 | - `githubFlavored` — overall reasonable improvements on the original
49 | markdown parser as described [here][gfm]. This includes stuff like [fenced
50 | code blocks][fenced] and [tables].
51 |
52 | - `html` — this determines if raw HTML should be allowed. If you
53 | are parsing user markdown or user input can somehow reach the markdown
54 | parser, you should almost certainly turn off HTML. If it is just you
55 | writing markdown, turning HTML on is a nice way to do some tricks if
56 | it is needed.
57 |
58 | - `breaks` — This will automatically convert `\n` in paragraphs
59 | to `
`.
60 |
61 | - `langPrefix` — CSS language class prefix for fenced blocks. Can be
62 | useful for external highlighters.
63 |
64 | - `defaultHighlighting` — a default language to use for code blocks that do
65 | not have a language tag. So setting this to `Just "elm"` will treat all
66 | unlabeled code blocks as Elm code. (This relies on [highlight.js][highlight]
67 | as explained in the README [here](../#code-blocks).)
68 |
69 | - `linkify` — This will automatically convert URL-like text to links.
70 |
71 | - `typographer` — This will automatically upgrade quotes to the
72 | prettier versions and turn dashes into [em dashes or en dashes][dash]
73 |
74 | - `quotes` — replace the "smart" double/single quotes that typographer
75 | adds when it is enabled. Examples:
76 |
77 | -- Russian
78 | { doubleLeft = "«", doubleRight = "»", singleLeft = "„", singleRight = "“" }
79 |
80 | -- German
81 | { doubleLeft = "„", doubleRight = "“", singleLeft = "‚", singleRight = "‘" }
82 |
83 | -- French
84 | { doubleLeft = "«\xA0", doubleRight = "\xA0»", singleLeft = "‹\xA0", singleRight = "\xA0›" }
85 |
86 | [gfm]: https://help.github.com/articles/github-flavored-markdown/
87 | [fenced]: https://help.github.com/articles/github-flavored-markdown/#fenced-code-blocks
88 | [tables]: https://help.github.com/articles/github-flavored-markdown/#tables
89 | [highlight]: https://highlightjs.org/
90 | [dash]: http://en.wikipedia.org/wiki/Dash
91 |
92 | -}
93 | type alias Options =
94 | { githubFlavored : Bool
95 | , html : Bool
96 | , breaks : Bool
97 | , langPrefix : Maybe String
98 | , defaultHighlighting : Maybe String
99 | , linkify : Bool
100 | , typographer : Bool
101 | , quotes :
102 | Maybe
103 | { doubleLeft : String
104 | , doubleRight : String
105 | , singleLeft : String
106 | , singleRight : String
107 | }
108 | }
109 |
110 |
111 | {-| The `Options` used by the `toHtml` function.
112 |
113 | { githubFlavored = True
114 | , html = True
115 | , breaks = True
116 | , langPrefix = Just "custom-class-"
117 | , defaultHighlighting = Just "markdown"
118 | , linkify = True
119 | , typographer = True
120 | , quotes = Just { doubleLeft = "«", doubleRight = "»", singleLeft = "„", singleRight = "“" }
121 | }
122 |
123 | -}
124 | defaultOptions : Options
125 | defaultOptions =
126 | { githubFlavored = False
127 | , html = False
128 | , breaks = False
129 | , langPrefix = Just "prism "
130 | , defaultHighlighting = Nothing
131 | , linkify = False
132 | , typographer = False
133 | , quotes = Nothing
134 | }
135 |
136 |
137 | {-| Maybe you want to enable HTML input in your markdown. To accomplish this,
138 | you can use modified parsing options.
139 |
140 | options : Options
141 | options =
142 | { defaultOptions | html = True }
143 |
144 | toMarkdown : String -> Html
145 | toMarkdown userInput =
146 | Markdown.toHtmlWith options [] userInput
147 |
148 | -}
149 | toHtmlWith : Options -> List (Attribute msg) -> String -> Html msg
150 | toHtmlWith =
151 | Native.Markdown.toHtml
152 |
--------------------------------------------------------------------------------
/app/elm/Models.elm:
--------------------------------------------------------------------------------
1 | module Models exposing (Model, Status(..))
2 |
3 | import Data.Comment exposing (Comment, Inserted)
4 | import Data.User exposing (User)
5 | import Navigation exposing (Location)
6 | import Time.DateTime exposing (DateTime)
7 |
8 |
9 | type alias Model =
10 | { comment : String --TODO: Should probably rename this now
11 | , parent : Maybe Int
12 | , user : User
13 | , comments : List Comment
14 | , count : Int
15 | , post : Location
16 | , title : String
17 | , debug : String
18 | , now : DateTime
19 | , editTimeout : Float
20 | , blogAuthor : String
21 | , status : Status
22 | }
23 |
24 |
25 | type Status
26 | = Commenting
27 | | Replying
28 | | Editing
29 |
--------------------------------------------------------------------------------
/app/elm/Msg.elm:
--------------------------------------------------------------------------------
1 | module Msg exposing (..)
2 |
3 | import Data.Comment exposing (Comment, Edited, Inserted)
4 | import Data.Init exposing (Init)
5 | import Http
6 | import Navigation exposing (Location)
7 | import Time exposing (Time)
8 | import Time.DateTime exposing (DateTime)
9 |
10 |
11 | type Msg
12 | = UpdateComment String
13 | | UpdateName (Maybe String)
14 | | UpdateEmail (Maybe String)
15 | | UpdateUrl (Maybe String)
16 | | UpdatePreview
17 | | SetPreview (Maybe String)
18 | | Count (Result Http.Error String)
19 | | Post Location
20 | | StoreUser
21 | | Title String
22 | | PostComment
23 | | PostConfirm (Result Http.Error Inserted)
24 | | Hashes (Result Http.Error Init)
25 | | Comments (Result Http.Error (List Comment))
26 | | GetDate Time
27 | | NewDate DateTime
28 | | CommentReply Int
29 | | CommentEdit Int
30 | | SendEdit Int
31 | | CommentDelete Int
32 | | CommentLike Int
33 | | CommentDislike Int
34 | | EditConfirm (Result Http.Error Edited)
35 | | DeleteConfirm (Result Http.Error Int)
36 | | LikeConfirm (Result Http.Error Int)
37 | | DislikeConfirm (Result Http.Error Int)
38 | | ToggleCommentVisibility Int
39 | | HardenEdit Int
40 |
--------------------------------------------------------------------------------
/app/elm/Native/Markdown.js:
--------------------------------------------------------------------------------
1 | var _Libbum$oration$Native_Markdown = function() {
2 |
3 |
4 | // VIRTUAL-DOM WIDGETS
5 |
6 | function toHtml(options, factList, rawMarkdown) {
7 | var model = {
8 | options: options,
9 | markdown: rawMarkdown
10 | };
11 | return _elm_lang$virtual_dom$Native_VirtualDom.custom(factList, model, implementation);
12 | }
13 |
14 |
15 | // WIDGET IMPLEMENTATION
16 |
17 | var implementation = {
18 | render: render,
19 | diff: diff
20 | };
21 |
22 | function render(model) {
23 | var html = parse(model.markdown, formatOptions(model.options));
24 | var div = document.createElement('div');
25 | div.innerHTML = html;
26 | return div;
27 | }
28 |
29 | function diff(a, b) {
30 | if (a.model.markdown === b.model.markdown && a.model.options === b.model.options) {
31 | return null;
32 | }
33 |
34 | return {
35 | applyPatch: applyPatch,
36 | data: parse(b.model.markdown, formatOptions(b.model.options))
37 | };
38 | }
39 |
40 | function applyPatch(domNode, data) {
41 | domNode.innerHTML = data;
42 | return domNode;
43 | }
44 |
45 |
46 | // ACTUAL MARKDOWN PARSER
47 |
48 | var parse = function() {
49 | // catch the parser object regardless of the outer environment.
50 | // (ex. a CommonJS module compatible environment.)
51 | // note that this depends on markdown-it's implementation of environment detection.
52 | var module = {};
53 | var exports = module.exports = {};
54 |
55 | // markdown-it 8.4.0 https://github.com//markdown-it/markdown-it @license MIT
56 | // markdown-it-katex 2.0.3 https://github.com/waylonflinn/markdown-it-katex/ @license MIT
57 | return function(text, options) {
58 | const md = require('markdown-it')(options.preset, options),
59 | mk = require('markdown-it-katex');
60 |
61 | md.use(mk);
62 | return md.render(text);
63 | };
64 | }();
65 |
66 |
67 | // FORMAT OPTIONS FOR PARSER IMPLEMENTATION
68 |
69 | function formatOptions(options) {
70 |
71 | function toHighlight(code, lang) {
72 | try {
73 | if (!lang && options.defaultHighlighting.ctor && options.defaultHighlighting.ctor === 'Just') {
74 | lang = options.defaultHighlighting._0;
75 | }
76 |
77 | if (typeof Prism !== 'undefined' && lang && Prism.languages[lang]) {
78 | return Prism.highlight(code, Prism.languages[lang]);
79 | }
80 |
81 | return code;
82 | } catch (e) {
83 | return code;
84 | }
85 | }
86 |
87 | options.highlight = toHighlight;
88 |
89 | // assign all 'Just' values and delete all 'Nothing' values
90 | for (var key in options) {
91 | var val = options[key];
92 | if (!val) {
93 | continue;
94 | }
95 | if (val.ctor === 'Just') {
96 | options[key] = val._0;
97 | } else if (val.ctor === 'Nothing') {
98 | delete options[key];
99 | }
100 | }
101 |
102 | if (options.githubFlavored) {
103 | options.preset = 'default';
104 | } else {
105 | options.preset = 'commonmark';
106 | }
107 |
108 | if (options.quotes) {
109 | options.quotes = [options.quotes.doubleLeft, options.quotes.doubleRight, options.quotes.singleLeft, options.quotes.singleRight];
110 | }
111 |
112 | return options;
113 | }
114 |
115 |
116 | // EXPORTS
117 |
118 | return {
119 | toHtml: F3(toHtml)
120 | };
121 |
122 | }();
123 |
--------------------------------------------------------------------------------
/app/elm/Ports.elm:
--------------------------------------------------------------------------------
1 | port module Ports exposing (email, name, preview, setEmail, setName, setPreview, setUrl, title, url)
2 |
3 | {- port for listening for document title from JavaScript -}
4 |
5 |
6 | port title : (String -> msg) -> Sub msg
7 |
8 |
9 |
10 | {- Get name from localStorage -}
11 |
12 |
13 | port name : (Maybe String -> msg) -> Sub msg
14 |
15 |
16 | port setName : Maybe String -> Cmd msg
17 |
18 |
19 |
20 | {- Get email from localStorage -}
21 |
22 |
23 | port email : (Maybe String -> msg) -> Sub msg
24 |
25 |
26 | port setEmail : Maybe String -> Cmd msg
27 |
28 |
29 |
30 | {- Get url from localStorage -}
31 |
32 |
33 | port url : (Maybe String -> msg) -> Sub msg
34 |
35 |
36 | port setUrl : Maybe String -> Cmd msg
37 |
38 |
39 |
40 | {- Get preview option from localStorage -}
41 |
42 |
43 | port preview : (Maybe String -> msg) -> Sub msg
44 |
45 |
46 | port setPreview : Maybe String -> Cmd msg
47 |
--------------------------------------------------------------------------------
/app/elm/Request/Comment.elm:
--------------------------------------------------------------------------------
1 | module Request.Comment exposing (comments, count, delete, dislike, edit, like, post)
2 |
3 | import Data.Comment as Comment exposing (Comment, Edited, Inserted)
4 | import Data.User exposing (Identity)
5 | import Http
6 | import HttpBuilder
7 | import Json.Decode as Decode
8 | import Models exposing (Model)
9 | import Navigation exposing (Location)
10 |
11 |
12 | {-| Request the number of comments for a given post
13 | -}
14 | count : Location -> Http.Request String
15 | count location =
16 | "/oration/count"
17 | |> HttpBuilder.get
18 | |> HttpBuilder.withQueryParams [ ( "url", location.pathname ) ]
19 | |> HttpBuilder.withExpect Http.expectString
20 | |> HttpBuilder.toRequest
21 |
22 |
23 | {-| We want to override the default post behaviour of the form and send this data seemlessly to the backend
24 | -}
25 | post : Model -> Http.Request Inserted
26 | post model =
27 | let
28 | --These values will always be sent
29 | body =
30 | [ ( "comment", model.comment )
31 | , ( "title", model.title )
32 | , ( "path", model.post.pathname )
33 | ]
34 |
35 | --User details are only sent if they exist
36 | expect =
37 | Comment.insertDecoder
38 | |> Http.expectJson
39 | in
40 | "/oration"
41 | |> HttpBuilder.post
42 | |> HttpBuilder.withUrlEncodedBody
43 | (prependMaybe body "parent" (Maybe.map toString model.parent)
44 | ++ prependMaybe body "name" model.user.name
45 | ++ prependMaybe body "email" model.user.email
46 | ++ prependMaybe body "url" model.user.url
47 | )
48 | |> HttpBuilder.withExpect expect
49 | |> HttpBuilder.toRequest
50 |
51 |
52 |
53 | {- Request to edit a given comment -}
54 |
55 |
56 | edit : Int -> Model -> Http.Request Edited
57 | edit id model =
58 | let
59 | --Only the comment itself and possibly author details can be edited
60 | --We need to send new author info, but the old hash to verify the edit
61 | body =
62 | [ ( "comment", model.comment ) ]
63 |
64 | --We post here since we are only sending a few pieces of information
65 | --See https://stormpath.com/blog/put-or-post
66 | expect =
67 | Comment.editDecoder
68 | |> Http.expectJson
69 | in
70 | "/oration/edit"
71 | |> HttpBuilder.post
72 | |> HttpBuilder.withHeader "x-auth-hash" model.user.identity
73 | |> HttpBuilder.withQueryParams [ ( "id", toString id ) ]
74 | |> HttpBuilder.withUrlEncodedBody
75 | (prependMaybe body "name" model.user.name
76 | ++ prependMaybe body "email" model.user.email
77 | ++ prependMaybe body "url" model.user.url
78 | )
79 | |> HttpBuilder.withExpect expect
80 | |> HttpBuilder.toRequest
81 |
82 |
83 |
84 | {- Request to like a given comment -}
85 |
86 |
87 | like : Int -> Http.Request Int
88 | like id =
89 | "/oration/like"
90 | |> HttpBuilder.post
91 | |> HttpBuilder.withQueryParams [ ( "id", toString id ) ]
92 | |> HttpBuilder.withExpect (Http.expectStringResponse (\response -> Ok (Result.withDefault -1 (String.toInt response.body))))
93 | |> HttpBuilder.toRequest
94 |
95 |
96 |
97 | {- Request to dislike a given comment -}
98 |
99 |
100 | dislike : Int -> Http.Request Int
101 | dislike id =
102 | "/oration/dislike"
103 | |> HttpBuilder.post
104 | |> HttpBuilder.withQueryParams [ ( "id", toString id ) ]
105 | |> HttpBuilder.withExpect (Http.expectStringResponse (\response -> Ok (Result.withDefault -1 (String.toInt response.body))))
106 | |> HttpBuilder.toRequest
107 |
108 |
109 |
110 | {- Request to delete a given comment -}
111 |
112 |
113 | delete : Int -> Identity -> Http.Request Int
114 | delete id identity =
115 | "/oration/delete"
116 | |> HttpBuilder.delete
117 | |> HttpBuilder.withHeader "x-auth-hash" identity
118 | |> HttpBuilder.withQueryParams [ ( "id", toString id ) ]
119 | |> HttpBuilder.withExpect (Http.expectStringResponse (\response -> Ok (Result.withDefault -1 (String.toInt response.body))))
120 | |> HttpBuilder.toRequest
121 |
122 |
123 | {-| Request the comments for the current url
124 | -}
125 | comments : Location -> Http.Request (List Comment)
126 | comments location =
127 | let
128 | expect =
129 | Decode.list Comment.decoder
130 | |> Decode.field "comments"
131 | |> Http.expectJson
132 | in
133 | "/oration/comments"
134 | |> HttpBuilder.get
135 | |> HttpBuilder.withQueryParams [ ( "url", location.pathname ) ]
136 | |> HttpBuilder.withExpect expect
137 | |> HttpBuilder.toRequest
138 |
139 |
140 | {-| Adds a pair to the list so long as there is data in the second
141 | -}
142 | prependMaybe : List ( a, a ) -> a -> Maybe a -> List ( a, a )
143 | prependMaybe list id maybe =
144 | case maybe of
145 | Just value ->
146 | ( id, value ) :: list
147 |
148 | Nothing ->
149 | list
150 |
--------------------------------------------------------------------------------
/app/elm/Request/Init.elm:
--------------------------------------------------------------------------------
1 | module Request.Init exposing (hashes)
2 |
3 | import Data.Init as Init exposing (Init)
4 | import Http
5 | import HttpBuilder
6 |
7 |
8 | {-| Request a hash of the users IP and the hash of the blog author (if set)
9 | -}
10 | hashes : Http.Request Init
11 | hashes =
12 | let
13 | expect =
14 | Init.decoder
15 | |> Http.expectJson
16 | in
17 | "/oration/init"
18 | |> HttpBuilder.get
19 | |> HttpBuilder.withExpect expect
20 | |> HttpBuilder.toRequest
21 |
--------------------------------------------------------------------------------
/app/elm/Style.elm:
--------------------------------------------------------------------------------
1 | module Style exposing (..)
2 |
3 | import Css exposing (..)
4 | import Css.Elements exposing (button, img, input, label, li, p, textarea, typeSelector)
5 | import Css.Namespace exposing (namespace)
6 | import Html.CssHelpers exposing (withNamespace)
7 |
8 |
9 | type OrationClasses
10 | = Submit
11 | | Footer
12 | | Response
13 | | Thread
14 | | BlogAuthor
15 | | Identicon
16 | | Author
17 | | Deleted
18 | | Date
19 | | Comment
20 | | Content
21 | | Form
22 | | Spacer
23 | | User
24 | | Control
25 | | Block
26 | | LeftMargin10
27 | | Hidden
28 | | Toggle
29 | | Votes
30 |
31 |
32 | type OrationIds
33 | = Oration
34 | | OrationComments
35 | | OrationForm
36 | | OrationReplyForm
37 | | OrationPreviewCheck
38 | | OrationCommentPreview
39 |
40 |
41 | orationNamespace : Html.CssHelpers.Namespace String class id msg
42 | orationNamespace =
43 | withNamespace "oration"
44 |
45 |
46 |
47 | {- Colors -}
48 |
49 |
50 | primaryColor : Color
51 | primaryColor =
52 | hex "6496c8"
53 |
54 |
55 | hoverColor : Color
56 | hoverColor =
57 | hex "346392"
58 |
59 |
60 | activeColor : Color
61 | activeColor =
62 | hex "27496d"
63 |
64 |
65 | css : Stylesheet
66 | css =
67 | (stylesheet << namespace orationNamespace.name)
68 | [ class Submit
69 | [ color (hex "fff")
70 | , textShadow3 (px -2) (px -2) hoverColor
71 | , backgroundColor (hex "ff9664")
72 | , backgroundImage (linearGradient2 toTop (stop primaryColor) (stop hoverColor) [])
73 | , cursor pointer
74 | , borderRadius (px 15)
75 | , border zero
76 | , boxShadow6 inset zero zero zero (px 1) activeColor
77 | , hover [ property "box-shadow" "inset 0 0 0 1px #27496d,0 5px 15px #193047" ]
78 | , active [ property "box-shadow" "inset 0 0 0 1px #27496d,0 5px 30px #193047" ]
79 | , disabled [ color (hex "ccc") ]
80 | , padding2 (px 10) (px 20)
81 | , marginLeft (px 20)
82 | ]
83 | , class Footer
84 | [ children
85 | [ button
86 | [ clickableStyle
87 | , border3 (px 1) solid primaryColor
88 | , borderRadius (px 15)
89 | , marginBottom (px 10)
90 | , marginRight (px 10)
91 | , fontSize (pt 10)
92 | , disabled
93 | [ cursor default
94 | , color (hex "ccc")
95 | ]
96 | ]
97 | ]
98 | ]
99 | , class Votes
100 | [ color primaryColor
101 | , marginRight (px 10)
102 | , children
103 | [ button
104 | [ clickableStyle
105 | , border3 (px 1) solid primaryColor
106 | , borderRadius (px 15)
107 | , fontSize (pt 10)
108 | , disabled
109 | [ cursor default
110 | , color (hex "ccc")
111 | ]
112 | ]
113 | ]
114 | ]
115 | , class Response
116 | [ paddingLeft (px 20)
117 | , border3 (px 1) solid (hex "F00")
118 | ]
119 | , class Control
120 | [ float right
121 | , display block
122 | , marginBottom (px 5)
123 | ]
124 | , class Block
125 | [ display block
126 | ]
127 | , class Hidden
128 | [ display none
129 | ]
130 | , class Toggle
131 | [ clickableStyle
132 | , border zero
133 | ]
134 | , class BlogAuthor
135 | [ backgroundColor (rgba 0 0 0 0.03) ]
136 | , class Author
137 | [ fontWeight bold
138 | , color (hex "555")
139 | ]
140 | , class Deleted
141 | [ fontStyle italic
142 | , color (hex "555")
143 | , padding2 zero (px 6)
144 | , marginBottom (px 10)
145 | , display inlineBlock
146 | ]
147 | , each [ class Date, class Spacer ]
148 | [ color (hex "666")
149 | , fontWeight normal
150 | , textShadow none
151 | ]
152 | , class Spacer
153 | [ padding2 zero (px 6)
154 | ]
155 | , class Content
156 | [ padding (px 0)
157 | , children
158 | [ p
159 | [ firstOfType
160 | [ marginTop (em 0.5) ]
161 | , lastOfType
162 | [ marginBottom (em 0.25) ]
163 | ]
164 | ]
165 | , descendants
166 | [ img
167 | [ maxWidth (pct 100)
168 | ]
169 | , Css.Elements.pre
170 | [ overflowX auto
171 | ]
172 | ]
173 | ]
174 | , class Comment
175 | [ padding (px 0) ]
176 | , class Identicon
177 | [ display inlineBlock
178 | , verticalAlign middle
179 | , marginRight (px 10)
180 | ]
181 | , class LeftMargin10
182 | [ marginLeft (px 10)
183 | ]
184 | , typeSelector "input[type=\"checkbox\"]"
185 | [ adjacentSiblings
186 | [ label
187 | [ lastChild
188 | [ marginBottom zero ]
189 | , before
190 | [ display inlineBlock
191 | , property "content" "''"
192 | , width (px 20)
193 | , height (px 20)
194 | , border3 (px 1) solid (rgba 0 0 0 0.5)
195 | , position absolute
196 | , left (px 5)
197 | , top zero
198 | , opacity (num 0.6)
199 | , property "-webkit-transition" "all .12s, border-color .08s"
200 | , property "transition" "all .12s, border-color .08s"
201 | ]
202 | , display inlineBlock
203 | , position relative
204 | , paddingLeft (px 30)
205 | , cursor pointer
206 | , property "-webkit-user-select" "none"
207 | , property "-moz-user-select" "none"
208 | , property "-ms-user-select" "none"
209 | ]
210 | ]
211 | , display none
212 | , checked
213 | [ adjacentSiblings
214 | [ label
215 | [ before
216 | [ width (px 10)
217 | , top (px -5)
218 | , left (px 10)
219 | , borderRadius zero
220 | , opacity (int 1)
221 | , borderTopColor transparent
222 | , borderLeftColor transparent
223 | , transform (rotate (deg 45))
224 | , property "-webkit-transform" "rotate(45deg)"
225 | ]
226 | ]
227 | ]
228 | ]
229 | ]
230 | , class Form
231 | [ children
232 | [ textarea [ inputStyle ]
233 | ]
234 | , margin3 zero auto (px 10)
235 | , float left
236 | ]
237 | , class User
238 | [ paddingTop (px 2)
239 | , paddingBottom (px 5)
240 | , children
241 | [ input
242 | [ inputStyle
243 | , width (px 162)
244 | ]
245 | ]
246 | ]
247 | , Css.Elements.code
248 | [ fontSize (px 14)
249 | ]
250 | , id OrationComments
251 | [ padding zero
252 | , children
253 | [ li
254 | [ firstChild
255 | [ border zero
256 | ]
257 | ]
258 | ]
259 | ]
260 | , li
261 | [ listStyleType none
262 | , borderTop3 (px 1) solid (rgba 0 0 0 0.2)
263 | , paddingTop (px 5)
264 | ]
265 | , id Oration
266 | [ width (px 597)
267 | ]
268 | ]
269 |
270 |
271 | inputStyle : Style
272 | inputStyle =
273 | batch
274 | [ padding2 (em 0.3) (px 10)
275 | , borderRadius (px 3)
276 | , lineHeight (em 1.4)
277 | , border3 (px 1) solid (rgba 0 0 0 0.2)
278 | , boxShadow4 zero (px 1) (px 2) (rgba 0 0 0 0.1)
279 | ]
280 |
281 |
282 | clickableStyle : Style
283 | clickableStyle =
284 | batch
285 | [ backgroundColor (rgba 0 0 0 0)
286 | , color primaryColor
287 | , cursor pointer
288 | , outline zero
289 | , hover [ borderColor hoverColor, color hoverColor ]
290 | , active [ borderColor activeColor, color activeColor ]
291 | ]
292 |
--------------------------------------------------------------------------------
/app/elm/Stylesheets.elm:
--------------------------------------------------------------------------------
1 | port module Stylesheets exposing (..)
2 |
3 | import Css.File exposing (CssCompilerProgram, CssFileStructure)
4 | import Css.Normalize
5 | import Style
6 |
7 |
8 | {- Stylesheets -}
9 |
10 |
11 | port files : CssFileStructure -> Cmd msg
12 |
13 |
14 | fileStructure : CssFileStructure
15 | fileStructure =
16 | Css.File.toFileStructure
17 | [ ( "oration.css", Css.File.compile [ Css.Normalize.css, Style.css ] ) ]
18 |
19 |
20 | main : CssCompilerProgram
21 | main =
22 | Css.File.compiler files fileStructure
23 |
--------------------------------------------------------------------------------
/app/elm/Time/DateTime/Distance.elm:
--------------------------------------------------------------------------------
1 | module Time.DateTime.Distance exposing (inWords)
2 |
3 | import Time.DateTime as DT
4 |
5 |
6 | type Interval
7 | = Second
8 | | Minute
9 | | Hour
10 | | Day
11 | | Month
12 | | Year
13 |
14 |
15 | type Distance
16 | = LessThanXSeconds Int
17 | | HalfAMinute
18 | | LessThanXMinutes Int
19 | | XMinutes Int
20 | | AboutXHours Int
21 | | XDays Int
22 | | AboutXMonths Int
23 | | XMonths Int
24 | | AboutXYears Int
25 | | OverXYears Int
26 | | AlmostXYears Int
27 |
28 |
29 | minutes_in_day : number
30 | minutes_in_day =
31 | 1440
32 |
33 |
34 | minutes_in_almost_two_days : number
35 | minutes_in_almost_two_days =
36 | 2520
37 |
38 |
39 | minutes_in_month : number
40 | minutes_in_month =
41 | 43200
42 |
43 |
44 | minutes_in_two_months : number
45 | minutes_in_two_months =
46 | 86400
47 |
48 |
49 | inWords : DT.DateTime -> DT.DateTime -> String
50 | inWords first second =
51 | let
52 | distance =
53 | calculateDistance <| DT.delta first second
54 | in
55 | fromDistance distance
56 |
57 |
58 | upToOneDay : Int -> Distance
59 | upToOneDay minutes =
60 | let
61 | hours =
62 | round <| toFloat minutes / 60
63 | in
64 | AboutXHours hours
65 |
66 |
67 | upToOneMonth : Int -> Distance
68 | upToOneMonth minutes =
69 | let
70 | days =
71 | round <| toFloat minutes / minutes_in_day
72 | in
73 | XDays days
74 |
75 |
76 | upToTwoMonths : Int -> Distance
77 | upToTwoMonths minutes =
78 | let
79 | months =
80 | round <| toFloat minutes / minutes_in_month
81 | in
82 | AboutXMonths months
83 |
84 |
85 | upToOneYear : Int -> Distance
86 | upToOneYear minutes =
87 | let
88 | nearestMonth =
89 | round <| toFloat minutes / minutes_in_month
90 | in
91 | XMonths nearestMonth
92 |
93 |
94 | calculateDistance : DT.DateTimeDelta -> Distance
95 | calculateDistance delta =
96 | let
97 | minutes =
98 | delta.minutes
99 |
100 | months =
101 | delta.months
102 |
103 | years =
104 | delta.years
105 | in
106 | if minutes == 0 then
107 | LessThanXMinutes 1
108 | else if minutes < 2 then
109 | XMinutes minutes
110 | else if minutes < 45 then
111 | -- 2 mins up to 0.75 hrs
112 | XMinutes minutes
113 | else if minutes < 90 then
114 | -- 0.75 hrs up to 1.5 hrs
115 | AboutXHours 1
116 | else if minutes < minutes_in_day then
117 | -- 1.5 hrs up to 24 hrs
118 | upToOneDay minutes
119 | else if minutes < minutes_in_almost_two_days then
120 | -- 1 day up to 1.75 days
121 | XDays 1
122 | else if minutes < minutes_in_month then
123 | -- 1.75 days up to 30 days
124 | upToOneMonth minutes
125 | else if minutes < minutes_in_two_months then
126 | -- 1 month up to 2 months
127 | upToTwoMonths minutes
128 | else if months < 12 then
129 | -- 2 months up to 12 months
130 | upToOneYear minutes
131 | else
132 | -- 1 year up to max Date
133 | let
134 | monthsSinceStartOfYear =
135 | months % 12
136 | in
137 | if monthsSinceStartOfYear < 3 then
138 | -- N years up to 1 years 3 months
139 | AboutXYears years
140 | else if monthsSinceStartOfYear < 9 then
141 | -- N years 3 months up to N years 9 months
142 | OverXYears years
143 | else
144 | -- N years 9 months up to N year 12 months
145 | AlmostXYears <| years + 1
146 |
147 |
148 | fromDistance : Distance -> String
149 | fromDistance distance =
150 | case distance of
151 | LessThanXSeconds i ->
152 | circa "less than" Second i
153 |
154 | HalfAMinute ->
155 | "half a minute"
156 |
157 | LessThanXMinutes i ->
158 | circa "less than" Minute i
159 |
160 | XMinutes i ->
161 | exact Minute i
162 |
163 | AboutXHours i ->
164 | circa "about" Hour i
165 |
166 | XDays i ->
167 | exact Day i
168 |
169 | AboutXMonths i ->
170 | circa "about" Month i
171 |
172 | XMonths i ->
173 | exact Month i
174 |
175 | AboutXYears i ->
176 | circa "about" Year i
177 |
178 | OverXYears i ->
179 | circa "over" Year i
180 |
181 | AlmostXYears i ->
182 | circa "almost" Year i
183 |
184 |
185 | formatInterval : Interval -> String
186 | formatInterval =
187 | String.toLower << toString
188 |
189 |
190 | singular : Interval -> String
191 | singular interval =
192 | case interval of
193 | Minute ->
194 | "a " ++ formatInterval interval
195 |
196 | _ ->
197 | "1 " ++ formatInterval interval
198 |
199 |
200 | circa : String -> Interval -> Int -> String
201 | circa prefix interval i =
202 | case i of
203 | 1 ->
204 | prefix ++ " " ++ singular interval ++ " ago"
205 |
206 | _ ->
207 | prefix ++ " " ++ toString i ++ " " ++ formatInterval interval ++ "s ago"
208 |
209 |
210 | exact : Interval -> Int -> String
211 | exact interval i =
212 | case i of
213 | 1 ->
214 | "1 " ++ formatInterval interval ++ " ago"
215 |
216 | _ ->
217 | toString i ++ " " ++ formatInterval interval ++ "s ago"
218 |
--------------------------------------------------------------------------------
/app/elm/Update.elm:
--------------------------------------------------------------------------------
1 | module Update exposing (currentDate, subscriptions, update)
2 |
3 | import Data.Comment as Comment
4 | import Data.User exposing (getIdentity)
5 | import Http
6 | import Maybe.Extra exposing ((?))
7 | import Models exposing (Model, Status(..))
8 | import Msg exposing (Msg(..))
9 | import Ports
10 | import Request.Comment
11 | import Task
12 | import Time exposing (minute)
13 | import Time.DateTime exposing (DateTime)
14 | import Util exposing (delay)
15 |
16 |
17 | update : Msg -> Model -> ( Model, Cmd Msg )
18 | update msg model =
19 | case msg of
20 | UpdateComment comment ->
21 | { model | comment = comment } ! []
22 |
23 | UpdateName name ->
24 | let
25 | user =
26 | model.user
27 | in
28 | { model | user = { user | name = name } } ! []
29 |
30 | UpdateEmail email ->
31 | let
32 | user =
33 | model.user
34 | in
35 | { model | user = { user | email = email } } ! []
36 |
37 | UpdateUrl url ->
38 | let
39 | user =
40 | model.user
41 | in
42 | { model | user = { user | url = url } } ! []
43 |
44 | UpdatePreview ->
45 | let
46 | user =
47 | model.user
48 | in
49 | { model | user = { user | preview = not model.user.preview } } ! []
50 |
51 | SetPreview strPreview ->
52 | let
53 | user =
54 | model.user
55 |
56 | preview_ =
57 | dumbDecode strPreview
58 | in
59 | { model | user = { user | preview = preview_ } } ! []
60 |
61 | StoreUser ->
62 | model
63 | ! [ Cmd.batch
64 | [ Ports.setName model.user.name
65 | , Ports.setEmail model.user.email
66 | , Ports.setUrl model.user.url
67 | , Ports.setPreview (Just <| toString model.user.preview)
68 | ]
69 | ]
70 |
71 | Count (Ok strCount) ->
72 | let
73 | intCount =
74 | case String.toInt strCount of
75 | Ok val ->
76 | val
77 |
78 | Err _ ->
79 | 0
80 | in
81 | { model | count = intCount } ! []
82 |
83 | Count (Err error) ->
84 | { model | debug = toString error } ! []
85 |
86 | Post location ->
87 | { model | post = location } ! []
88 |
89 | Title value ->
90 | { model | title = value } ! []
91 |
92 | PostComment ->
93 | model
94 | ! [ let
95 | postReq =
96 | Request.Comment.post model
97 | |> Http.toTask
98 | in
99 | Task.attempt PostConfirm postReq
100 | ]
101 |
102 | PostConfirm (Ok result) ->
103 | let
104 | user =
105 | model.user
106 |
107 | author =
108 | getIdentity user
109 |
110 | comments =
111 | Comment.insertNew result ( model.comment, author, model.now, model.comments )
112 | in
113 | { model
114 | | comment = ""
115 | , parent = Nothing
116 | , count = model.count + 1
117 | , debug = toString result
118 | , comments = comments
119 | , status = Commenting
120 | , user = { user | identity = author }
121 | }
122 | ! [ timeoutEdits model.editTimeout result.id ]
123 |
124 | PostConfirm (Err error) ->
125 | { model | debug = toString error } ! []
126 |
127 | Hashes (Ok result) ->
128 | let
129 | user =
130 | model.user
131 | in
132 | { model
133 | | user =
134 | { user
135 | | iphash = result.userIp
136 | , identity = getIdentity user
137 | }
138 | , blogAuthor = result.blogAuthor ? ""
139 | , editTimeout = result.editTimeout
140 | }
141 | ! []
142 |
143 | Hashes (Err error) ->
144 | { model | debug = toString error } ! []
145 |
146 | Comments (Ok result) ->
147 | let
148 | count =
149 | Comment.count result
150 | in
151 | { model
152 | | comments = result
153 | , count = count
154 | }
155 | ! []
156 |
157 | Comments (Err error) ->
158 | { model | debug = toString error } ! []
159 |
160 | GetDate _ ->
161 | model ! [ Task.perform NewDate currentDate ]
162 |
163 | NewDate date ->
164 | { model | now = date } ! []
165 |
166 | CommentReply id ->
167 | let
168 | value =
169 | if model.parent == Just id then
170 | Nothing
171 | else
172 | Just id
173 |
174 | status =
175 | if model.parent == Just id then
176 | Commenting
177 | else
178 | Replying
179 | in
180 | { model
181 | | parent = value
182 | , status = status
183 | }
184 | ! []
185 |
186 | CommentEdit id ->
187 | let
188 | value =
189 | if model.parent == Just id then
190 | Nothing
191 | else
192 | Just id
193 |
194 | status =
195 | if model.parent == Just id then
196 | Commenting
197 | else
198 | Editing
199 |
200 | comment =
201 | if model.parent == Just id then
202 | ""
203 | else
204 | Comment.getText id model.comments
205 | in
206 | { model
207 | | parent = value
208 | , comment = comment
209 | , status = status
210 | }
211 | ! [ timeoutEdits model.editTimeout id ]
212 |
213 | SendEdit id ->
214 | model
215 | ! [ let
216 | postReq =
217 | Request.Comment.edit id model
218 | |> Http.toTask
219 | in
220 | Task.attempt EditConfirm postReq
221 | ]
222 |
223 | EditConfirm (Ok result) ->
224 | let
225 | user =
226 | model.user
227 |
228 | comments =
229 | Comment.update result model.comments
230 | in
231 | { model
232 | | debug = toString result
233 | , status = Commenting
234 | , comments = comments
235 | , comment = ""
236 | , parent = Nothing
237 | , user = { user | identity = getIdentity user }
238 | }
239 | ! [ timeoutEdits model.editTimeout result.id ]
240 |
241 | EditConfirm (Err error) ->
242 | { model | debug = toString error } ! []
243 |
244 | CommentDelete id ->
245 | model
246 | ! [ let
247 | postReq =
248 | Request.Comment.delete id model.user.identity
249 | |> Http.toTask
250 | in
251 | Task.attempt DeleteConfirm postReq
252 | ]
253 |
254 | DeleteConfirm (Ok result) ->
255 | let
256 | comments =
257 | Comment.delete result model.comments
258 | in
259 | { model
260 | | debug = toString result
261 | , comments = comments
262 | }
263 | ! []
264 |
265 | DeleteConfirm (Err error) ->
266 | { model | debug = toString error } ! []
267 |
268 | CommentLike id ->
269 | model
270 | ! [ let
271 | postReq =
272 | Request.Comment.like id
273 | |> Http.toTask
274 | in
275 | Task.attempt LikeConfirm postReq
276 | ]
277 |
278 | LikeConfirm (Ok result) ->
279 | let
280 | comments =
281 | Comment.like result model.comments
282 | in
283 | { model
284 | | debug = toString result
285 | , comments = comments
286 | }
287 | ! []
288 |
289 | LikeConfirm (Err error) ->
290 | let
291 | comments =
292 | case error of
293 | Http.BadStatus status ->
294 | Comment.disableVote (Result.withDefault -1 (String.toInt status.body)) model.comments
295 |
296 | _ ->
297 | model.comments
298 |
299 | print =
300 | case error of
301 | Http.BadStatus status ->
302 | toString status.status ++ ", " ++ status.body
303 |
304 | _ ->
305 | toString error
306 | in
307 | { model
308 | | debug = print
309 | , comments = comments
310 | }
311 | ! []
312 |
313 | CommentDislike id ->
314 | model
315 | ! [ let
316 | postReq =
317 | Request.Comment.dislike id
318 | |> Http.toTask
319 | in
320 | Task.attempt DislikeConfirm postReq
321 | ]
322 |
323 | DislikeConfirm (Ok result) ->
324 | let
325 | comments =
326 | Comment.dislike result model.comments
327 | in
328 | { model
329 | | debug = toString result
330 | , comments = comments
331 | }
332 | ! []
333 |
334 | DislikeConfirm (Err error) ->
335 | let
336 | comments =
337 | case error of
338 | Http.BadStatus status ->
339 | Comment.disableVote (Result.withDefault -1 (String.toInt status.body)) model.comments
340 |
341 | _ ->
342 | model.comments
343 |
344 | print =
345 | case error of
346 | Http.BadStatus status ->
347 | toString status.status ++ ", " ++ status.body
348 |
349 | _ ->
350 | toString error
351 | in
352 | { model
353 | | debug = print
354 | , comments = comments
355 | }
356 | ! []
357 |
358 | HardenEdit id ->
359 | let
360 | comments =
361 | case model.status of
362 | Editing ->
363 | model.comments
364 |
365 | _ ->
366 | Comment.readOnly id model.comments
367 | in
368 | { model | comments = comments } ! []
369 |
370 | ToggleCommentVisibility id ->
371 | let
372 | comments =
373 | Comment.toggleVisible id model.comments
374 | in
375 | { model | comments = comments } ! []
376 |
377 |
378 | subscriptions : Model -> Sub Msg
379 | subscriptions model =
380 | Sub.batch
381 | [ Ports.title Title
382 | , Ports.name UpdateName
383 | , Ports.email UpdateEmail
384 | , Ports.url UpdateUrl
385 | , Ports.preview SetPreview
386 | , Time.every minute GetDate
387 | ]
388 |
389 |
390 | {-| localStorage values are always strings. We store the preview bool via toString, so this will be good enough as a decoder.
391 | -}
392 | dumbDecode : Maybe String -> Bool
393 | dumbDecode val =
394 | case val of
395 | Just decoded ->
396 | if decoded == "True" then
397 | True
398 | else
399 | False
400 |
401 | Nothing ->
402 | False
403 |
404 |
405 | currentDate : Task.Task x DateTime
406 | currentDate =
407 | Time.now |> Task.map timeToDateTime
408 |
409 |
410 | timeToDateTime : Time.Time -> DateTime
411 | timeToDateTime =
412 | Time.DateTime.fromTimestamp
413 |
414 |
415 | timeoutEdits : Float -> Int -> Cmd Msg
416 | timeoutEdits timeout id =
417 | delay (Time.second * timeout) <| HardenEdit id
418 |
--------------------------------------------------------------------------------
/app/elm/Util.elm:
--------------------------------------------------------------------------------
1 | module Util exposing ((=>), delay, nothing, pair, stringToMaybe)
2 |
3 | import Html exposing (Html, text)
4 | import Process
5 | import Task
6 | import Time exposing (Time)
7 |
8 |
9 | (=>) : a -> b -> ( a, b )
10 | (=>) =
11 | (,)
12 |
13 |
14 | {-| infixl 0 means the (=>) operator has the same precedence as (<|) and (|>),
15 | meaning you can use it at the end of a pipeline and have the precedence work out.
16 | -}
17 | infixl 0 =>
18 |
19 |
20 | {-| Useful when building up a Cmd via a pipeline, and then pairing it with
21 | a model at the end.
22 | session.user
23 | |> User.Request.foo
24 | |> Task.attempt Foo
25 | |> pair { model | something = blah }
26 | -}
27 | pair : a -> b -> ( a, b )
28 | pair first second =
29 | first => second
30 |
31 |
32 | stringToMaybe : String -> Maybe String
33 | stringToMaybe val =
34 | if String.isEmpty val then
35 | Nothing
36 | else
37 | Just val
38 |
39 |
40 | nothing : Html msg
41 | nothing =
42 | text ""
43 |
44 |
45 |
46 | {- Invoke a time delay for an action -}
47 |
48 |
49 | delay : Time -> msg -> Cmd msg
50 | delay time msg =
51 | Process.sleep time
52 | |> Task.andThen (always <| Task.succeed msg)
53 | |> Task.perform identity
54 |
--------------------------------------------------------------------------------
/app/elm/View.elm:
--------------------------------------------------------------------------------
1 | module View exposing (view)
2 |
3 | import Data.Comment exposing (Comment, Responses(Responses), count)
4 | import Data.User exposing (Identity, getIdentity)
5 | import Html exposing (..)
6 | import Html.Attributes exposing (autocomplete, checked, cols, defaultValue, disabled, for, href, method, minlength, name, placeholder, rows, type_, value)
7 | import Html.Events exposing (onClick, onInput, onSubmit)
8 | import Identicon exposing (identicon)
9 | import Markdown exposing (defaultOptions)
10 | import Maybe.Extra exposing ((?), isJust, isNothing)
11 | import Models exposing (Model, Status(..))
12 | import Msg exposing (Msg(..))
13 | import Style
14 | import Time.DateTime.Distance exposing (inWords)
15 | import Util exposing (nothing, stringToMaybe)
16 |
17 |
18 | {- Sync up stylsheets -}
19 |
20 |
21 | { id, class, classList } =
22 | Style.orationNamespace
23 |
24 |
25 | view : Model -> Html Msg
26 | view model =
27 | let
28 | count =
29 | toString model.count
30 | ++ (if model.count /= 1 then
31 | " comments"
32 | else
33 | " comment"
34 | )
35 | in
36 | div [ id Style.Oration ]
37 | [ h2 [] [ text count ]
38 | , commentForm model Nothing
39 | , ul [ id Style.OrationComments ] <| printComments model
40 | ]
41 |
42 |
43 |
44 | {- Comment form. Can be used as the main form or in a reply. -}
45 |
46 |
47 | commentForm : Model -> Maybe Int -> Html Msg
48 | commentForm model commentId =
49 | let
50 | -- Even though we have model.user.identity, this is a semi-persistent copy
51 | -- for editing and deleting authorisation. Here, we want up-to-date identicons
52 | identity =
53 | getIdentity model.user
54 |
55 | name_ =
56 | model.user.name ? ""
57 |
58 | email_ =
59 | model.user.email ? ""
60 |
61 | url_ =
62 | model.user.url ? ""
63 |
64 | textAreaValue =
65 | case model.status of
66 | Commenting ->
67 | if isNothing model.parent then
68 | model.comment
69 | else
70 | ""
71 |
72 | _ ->
73 | model.comment
74 |
75 | formID =
76 | case model.status of
77 | Commenting ->
78 | Style.OrationForm
79 |
80 | _ ->
81 | Style.OrationReplyForm
82 |
83 | formDisable =
84 | if isNothing commentId && isJust model.parent then
85 | True
86 | else
87 | False
88 |
89 | buttonDisable =
90 | if formDisable then
91 | True
92 | else
93 | setButtonDisabled model.comment
94 |
95 | submitText =
96 | if isNothing commentId then
97 | "Comment"
98 | --The main form is never a reply or update
99 | else
100 | case model.status of
101 | Commenting ->
102 | "Comment"
103 |
104 | Editing ->
105 | "Update"
106 |
107 | Replying ->
108 | "Reply"
109 |
110 | submitCmd =
111 | case model.status of
112 | Editing ->
113 | SendEdit (commentId ? -1)
114 |
115 | _ ->
116 | PostComment
117 |
118 | preview =
119 | if formDisable then
120 | nothing
121 | else
122 | Markdown.toHtmlWith options [ id Style.OrationCommentPreview ] <|
123 | markdownContent model.comment model.user.preview
124 | in
125 | Html.form [ method "post", id formID, class [ Style.Form ], onSubmit submitCmd ]
126 | [ textarea
127 | [ name "comment"
128 | , placeholder "Write a comment here (min 3 characters)."
129 | , value textAreaValue
130 | , minlength 3
131 | , cols 80
132 | , rows 4
133 | , onInput UpdateComment
134 | , disabled formDisable
135 | , class [ Style.Block ]
136 | ]
137 | []
138 | , div [ class [ Style.User ] ]
139 | [ span [ class [ Style.Identicon, Style.LeftMargin10 ] ] [ identicon "25px" identity ]
140 | , input [ type_ "text", name "name", placeholder "Name (optional)", defaultValue name_, autocomplete True, onInput (\name -> UpdateName <| stringToMaybe name) ] []
141 | , input [ type_ "email", name "email", placeholder "Email (optional)", defaultValue email_, autocomplete True, onInput (\email -> UpdateEmail <| stringToMaybe email) ] []
142 | , input [ type_ "url", name "url", placeholder "Website (optional)", defaultValue url_, onInput (\url -> UpdateUrl <| stringToMaybe url) ] []
143 | ]
144 | , div [ class [ Style.Control ] ]
145 | [ input [ type_ "checkbox", id Style.OrationPreviewCheck, checked model.user.preview, onClick UpdatePreview ] []
146 | , label [ for (toString Style.OrationPreviewCheck) ] [ text "Preview" ]
147 | , input [ type_ "submit", class [ Style.Submit ], disabled buttonDisable, value submitText, onClick StoreUser ] []
148 | ]
149 | , preview
150 | ]
151 |
152 |
153 |
154 | {- Only allows users to comment if their comment is longer than 3 characters -}
155 |
156 |
157 | setButtonDisabled : String -> Bool
158 | setButtonDisabled comment =
159 | if String.length comment > 3 then
160 | False
161 | else
162 | True
163 |
164 |
165 |
166 | {- Renders comments to markdown -}
167 |
168 |
169 | options : Markdown.Options
170 | options =
171 | { defaultOptions
172 | | githubFlavored = True
173 | , breaks = True
174 | , typographer = True
175 | , linkify = True
176 | }
177 |
178 |
179 | markdownContent : String -> Bool -> String
180 | markdownContent content preview =
181 | if preview then
182 | content
183 | else
184 | ""
185 |
186 |
187 |
188 | {- Format a list of comments -}
189 |
190 |
191 | printComments : Model -> List (Html Msg)
192 | printComments model =
193 | List.map (\c -> printComment c model) model.comments
194 |
195 |
196 |
197 | {- Format a single comment -}
198 |
199 |
200 | printComment : Comment -> Model -> Html Msg
201 | printComment comment model =
202 | let
203 | notDeleted =
204 | if String.isEmpty comment.text && String.isEmpty comment.hash then
205 | False
206 | else
207 | True
208 |
209 | author =
210 | comment.author ? "Anonymous"
211 |
212 | created =
213 | inWords model.now comment.created
214 |
215 | commentId =
216 | "comment-" ++ toString comment.id
217 |
218 | headerStyle =
219 | if comment.hash == model.blogAuthor && notDeleted then
220 | [ Style.Thread, Style.BlogAuthor ]
221 | else
222 | [ Style.Thread ]
223 |
224 | contentStyle =
225 | if comment.visible then
226 | [ Style.Comment ]
227 | else
228 | [ Style.Hidden ]
229 |
230 | visibleButtonText =
231 | if comment.visible then
232 | "[–]"
233 | else
234 | "[+" ++ toString (count <| List.singleton comment) ++ "]"
235 | in
236 | if notDeleted then
237 | li [ id commentId, class headerStyle ]
238 | [ span [ class [ Style.Identicon ] ] [ identicon "25px" comment.hash ]
239 | , printAuthor author
240 | , span [ class [ Style.Spacer ] ] [ text "•" ]
241 | , span [ class [ Style.Date ] ] [ text created ]
242 | , button [ class [ Style.Toggle ], onClick (ToggleCommentVisibility comment.id) ] [ text visibleButtonText ]
243 | , div [ class contentStyle ]
244 | [ Markdown.toHtmlWith options [ class [ Style.Content ] ] comment.text
245 | , printFooter model.status model.user.identity comment
246 | , replyForm comment.id model
247 | , printResponses comment.children model
248 | ]
249 | ]
250 | else
251 | li [ id commentId, class headerStyle ]
252 | [ span [ class [ Style.Deleted ] ] [ text "Deleted comment" ]
253 | , span [ class [ Style.Spacer ] ] [ text "•" ]
254 | , span [ class [ Style.Date ] ] [ text created ]
255 | , button [ class [ Style.Toggle ], onClick (ToggleCommentVisibility comment.id) ] [ text visibleButtonText ]
256 | , div [ class contentStyle ] [ printResponses comment.children model ]
257 | ]
258 |
259 |
260 | printAuthor : String -> Html Msg
261 | printAuthor author =
262 | if String.startsWith "http://" author || String.startsWith "https://" author then
263 | a [ class [ Style.Author ], href author ] [ text author ]
264 | else
265 | span [ class [ Style.Author ] ] [ text author ]
266 |
267 |
268 | printFooter : Status -> Identity -> Comment -> Html Msg
269 | printFooter status identity comment =
270 | let
271 | replyText =
272 | case status of
273 | Replying ->
274 | "close"
275 |
276 | _ ->
277 | "reply"
278 |
279 | editText =
280 | case status of
281 | Editing ->
282 | "close"
283 |
284 | _ ->
285 | "edit"
286 |
287 | replyDisabled =
288 | case status of
289 | Editing ->
290 | True
291 |
292 | _ ->
293 | False
294 |
295 | editDisabled =
296 | case status of
297 | Replying ->
298 | True
299 |
300 | _ ->
301 | False
302 |
303 | deleteDisabled =
304 | case status of
305 | Commenting ->
306 | False
307 |
308 | _ ->
309 | True
310 |
311 | votingDisabled =
312 | if comment.votable && comment.hash /= identity then
313 | False
314 | else
315 | True
316 |
317 | edit =
318 | if comment.editable then
319 | button [ onClick (CommentEdit comment.id), disabled editDisabled ] [ text editText ]
320 | else
321 | nothing
322 |
323 | delete =
324 | if comment.editable then
325 | button [ onClick (CommentDelete comment.id), disabled deleteDisabled ] [ text "delete" ]
326 | else
327 | nothing
328 |
329 | votes =
330 | if comment.votes == 0 then
331 | " "
332 | else
333 | " " ++ toString comment.votes
334 | in
335 | span [ class [ Style.Footer ] ]
336 | [ span [ class [ Style.Votes ] ]
337 | [ button [ onClick (CommentLike comment.id), disabled votingDisabled ] [ text "⮝" ]
338 | , button [ onClick (CommentDislike comment.id), disabled votingDisabled ] [ text "⮟" ]
339 | , text votes
340 | ]
341 | , button [ onClick (CommentReply comment.id), disabled replyDisabled ] [ text replyText ]
342 | , edit
343 | , delete
344 | ]
345 |
346 |
347 | printResponses : Responses -> Model -> Html Msg
348 | printResponses (Responses responses) model =
349 | if List.isEmpty responses then
350 | nothing
351 | else
352 | ul [] <|
353 | List.map (\c -> printComment c model) responses
354 |
355 |
356 | replyForm : Int -> Model -> Html Msg
357 | replyForm id model =
358 | case model.status of
359 | Commenting ->
360 | nothing
361 |
362 | _ ->
363 | case model.parent of
364 | Just val ->
365 | if id == val then
366 | commentForm model (Just id)
367 | else
368 | nothing
369 |
370 | Nothing ->
371 | nothing
372 |
--------------------------------------------------------------------------------
/app/elm/elm-package.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.1.0",
3 | "summary": "A Rocket/Elm self hosted commenting system for static sites",
4 | "repository": "https://github.com/Libbum/oration.git",
5 | "license": "MIT",
6 | "source-directories": [
7 | "."
8 | ],
9 | "exposed-modules": [],
10 | "native-modules": true,
11 | "dependencies": {
12 | "NoRedInk/elm-decode-pipeline": "3.0.0 <= v < 4.0.0",
13 | "elm-community/elm-time": "1.0.11 <= v < 2.0.0",
14 | "elm-community/json-extra": "2.3.0 <= v < 3.0.0",
15 | "elm-community/maybe-extra": "4.0.0 <= v < 5.0.0",
16 | "elm-lang/core": "5.1.1 <= v < 6.0.0",
17 | "elm-lang/dom": "1.1.1 <= v < 2.0.0",
18 | "elm-lang/html": "2.0.0 <= v < 3.0.0",
19 | "elm-lang/http": "1.0.0 <= v < 2.0.0",
20 | "elm-lang/navigation": "2.1.0 <= v < 3.0.0",
21 | "ktonon/elm-crypto": "1.1.0 <= v < 2.0.0",
22 | "lukewestby/elm-http-builder": "5.1.0 <= v < 6.0.0",
23 | "pukkamustard/elm-identicon": "3.0.0 <= v < 4.0.0",
24 | "rtfeldman/elm-css": "11.2.0 <= v < 12.0.0",
25 | "rtfeldman/elm-css-helpers": "2.1.0 <= v < 3.0.0",
26 | "scottcorgan/elm-css-normalize": "1.1.9 <= v < 2.0.0"
27 | },
28 | "elm-version": "0.18.0 <= v < 0.19.0"
29 | }
30 |
--------------------------------------------------------------------------------
/app/elm/tests/Helpers/Dates.elm:
--------------------------------------------------------------------------------
1 | module Helpers.Dates exposing (..)
2 |
3 | import Fuzz exposing (Fuzzer, int, intRange)
4 | import Time.Date exposing (isLeapYear)
5 | import Time.DateTime exposing (DateTime, addDays, dateTime, zero)
6 |
7 |
8 | dateForYear : Int -> Fuzzer DateTime
9 | dateForYear year =
10 | let
11 | daysUpper =
12 | if isLeapYear year then
13 | 365
14 | else
15 | 364
16 | in
17 | intRange 0 daysUpper
18 | |> Fuzz.map (\days -> addDays days (dateTime { zero | year = year }))
19 |
20 |
21 | dateWithinYearRange : Int -> Int -> Fuzzer DateTime
22 | dateWithinYearRange lower upper =
23 | intRange lower upper
24 | |> Fuzz.andThen (\year -> dateForYear year)
25 |
--------------------------------------------------------------------------------
/app/elm/tests/Tests.elm:
--------------------------------------------------------------------------------
1 | module Tests exposing (..)
2 |
3 | import Data.Comment exposing (Comment, Responses(Responses))
4 | import Data.User exposing (User)
5 | import Expect exposing (..)
6 | import Fuzz exposing (Fuzzer, bool, int, list, maybe, string, tuple)
7 | import Helpers.Dates exposing (dateWithinYearRange)
8 | import Json.Decode as Decode
9 | import Json.Encode as Encode
10 | import Test exposing (..)
11 | import Time.DateTime exposing (DateTime)
12 | import Util exposing (..)
13 |
14 |
15 | user : Fuzzer User
16 | user =
17 | Fuzz.map5 User
18 | (maybe string)
19 | (maybe string)
20 | (maybe string)
21 | (maybe string)
22 | bool
23 | |> Fuzz.andMap string
24 |
25 |
26 |
27 | {-
28 | commentEncode : Fuzzer Comment
29 | commentEncode =
30 | Fuzz.map3 Comment
31 | string
32 | (maybe string)
33 | string
34 |
35 |
36 |
37 | commentDecode : Fuzzer Comment
38 | commentDecode =
39 | Fuzz.map5 Comment
40 | string
41 | (maybe string)
42 | string
43 | (maybe (dateNear 2017))
44 | int
45 | |> Fuzz.andMap (list (Fuzz.constant []))
46 |
47 | -}
48 |
49 |
50 | dateNear : Int -> Fuzzer DateTime
51 | dateNear y =
52 | dateWithinYearRange (y - 2) (y + 2)
53 |
54 |
55 | all : Test
56 | all =
57 | describe "Oration Front-end Test Suite"
58 | [ describe "Unit tests"
59 | [ test "String to Maybe String - Empty String" <|
60 | \() ->
61 | Expect.equal (stringToMaybe "") Nothing
62 | , test "String to Maybe String - String" <|
63 | \() ->
64 | Expect.equal (stringToMaybe "Something") (Just "Something")
65 | ]
66 | , describe "Fuzz tests"
67 | [ fuzz2 int (list string) "Pair generates tuples" <|
68 | \first second ->
69 | pair first second
70 | |> Expect.equal ( first, second )
71 | ]
72 | , describe "Data.User"
73 | [ fuzz user "Serialization round trip" <|
74 | \thisUser ->
75 | thisUser
76 | |> Data.User.encode
77 | |> Decode.decodeValue Data.User.decoder
78 | |> Expect.equal (Ok thisUser)
79 | ]
80 |
81 | {- , describe "Data.Comment"
82 | [ fuzz commentDecode "Serialization Decode" <|
83 | \thisComment ->
84 | thisComment
85 | |> Decode.decodeValue Data.Comment.decoder
86 | |> Expect.equal (Ok thisComment)
87 | ]
88 | -}
89 | ]
90 |
--------------------------------------------------------------------------------
/app/elm/tests/elm-package.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.1.0",
3 | "summary": "Test Suites",
4 | "repository": "https://github.com/Libbum/oration.git",
5 | "license": "MIT",
6 | "source-directories": [
7 | "..",
8 | "."
9 | ],
10 | "exposed-modules": [],
11 | "native-modules": true,
12 | "dependencies": {
13 | "NoRedInk/elm-decode-pipeline": "3.0.0 <= v < 4.0.0",
14 | "elm-community/elm-test": "4.0.0 <= v < 5.0.0",
15 | "elm-community/elm-time": "1.0.11 <= v < 2.0.0",
16 | "elm-community/json-extra": "2.3.0 <= v < 3.0.0",
17 | "elm-community/maybe-extra": "4.0.0 <= v < 5.0.0",
18 | "elm-lang/core": "5.1.1 <= v < 6.0.0",
19 | "elm-lang/dom": "1.1.1 <= v < 2.0.0",
20 | "elm-lang/html": "2.0.0 <= v < 3.0.0",
21 | "elm-lang/http": "1.0.0 <= v < 2.0.0",
22 | "elm-lang/navigation": "2.1.0 <= v < 3.0.0",
23 | "ktonon/elm-crypto": "1.1.0 <= v < 2.0.0",
24 | "lukewestby/elm-http-builder": "5.1.0 <= v < 6.0.0",
25 | "pukkamustard/elm-identicon": "3.0.0 <= v < 4.0.0",
26 | "rtfeldman/elm-css": "11.2.0 <= v < 12.0.0",
27 | "rtfeldman/elm-css-helpers": "2.1.0 <= v < 3.0.0",
28 | "scottcorgan/elm-css-normalize": "1.1.9 <= v < 2.0.0"
29 | },
30 | "elm-version": "0.18.0 <= v < 0.19.0"
31 | }
32 |
--------------------------------------------------------------------------------
/app/js/oration.js:
--------------------------------------------------------------------------------
1 | import Elm from './main';
2 | const elmDiv = document.getElementById('comments');
3 | if (elmDiv) {
4 | var app = Elm.Main.embed(elmDiv);
5 |
6 | function closestTitle(el) {
7 | var previous;
8 | // traverse previous elements to look for a h1
9 | while (el) {
10 | previous = el.previousElementSibling;
11 | if (previous && previous.tagName == "H1") {
12 | return previous.innerText;
13 | }
14 | el = previous;
15 | }
16 | return document.title; //If not found, use the page title
17 | }
18 |
19 | function setStore(state, location) {
20 | if (state) {
21 | localStorage.setItem(location, state);
22 | } else {
23 | localStorage.removeItem(location, state);
24 | }
25 | }
26 |
27 | var name = localStorage.getItem('orationName');
28 | var email = localStorage.getItem('orationEmail');
29 | var url = localStorage.getItem('orationUrl');
30 | var preview = localStorage.getItem('orationPreview');
31 |
32 | app.ports.title.send(closestTitle(elmDiv));
33 | app.ports.name.send(name);
34 | app.ports.email.send(email);
35 | app.ports.url.send(url);
36 | app.ports.preview.send(preview);
37 |
38 | app.ports.setName.subscribe(function(state) { setStore(state, 'orationName'); });
39 | app.ports.setEmail.subscribe(function(state) { setStore(state, 'orationEmail'); });
40 | app.ports.setUrl.subscribe(function(state) { setStore(state, 'orationUrl'); });
41 | app.ports.setPreview.subscribe(function(state) { setStore(state, 'orationPreview'); });
42 | }
43 |
--------------------------------------------------------------------------------
/app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "oration",
3 | "version": "0.1.0",
4 | "description": "A Rocket/Elm self hosted commenting system for static sites",
5 | "author": "Tim DuBois Index page
24 |
Here is a post. Please comment below, or go somewhere else.
26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/static/post-2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 24 |Because you liked the first post so much, I wrote another.
26 | 27 | 28 | 29 | 30 |4 |
5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 21 | 22 | 23 |
31 |