Compare commits
No commits in common. "develop" and "main" have entirely different histories.
|
@ -2,11 +2,8 @@
|
|||
/target/
|
||||
**/*.rs.bk
|
||||
|
||||
# GNOME builder stuff
|
||||
.buildconfig
|
||||
|
||||
# Sass stuff
|
||||
.sass-cache/
|
||||
|
||||
# Notes
|
||||
notes/
|
||||
# Lists
|
||||
lists/*.list
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# Version 1.0.1 (3 Jan 2018)
|
||||
|
||||
Add a More/Less button for uncollapsing/collapsing long notes that
|
||||
Add a More/Less button for uncollapsing/collapsing long lists that
|
||||
have been cut using a horizontal rule.
|
||||
|
||||
# Version 1.0.0 (2 Jan 2018)
|
||||
|
|
File diff suppressed because it is too large
Load Diff
28
Cargo.toml
28
Cargo.toml
|
@ -1,23 +1,25 @@
|
|||
[package]
|
||||
name = "rocket-pinboard"
|
||||
name = "wishlists"
|
||||
version = "1.0.1"
|
||||
authors = ["Paul van Tilburg <paul@luon.net>"]
|
||||
description = """
|
||||
A web application (based on Rocket) for maintaining a pinboard of notes for
|
||||
a family or group of friends.
|
||||
A web application for maintaining a board of wishlists for a family or
|
||||
group of friends.
|
||||
"""
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
Inflector = "0.11.4"
|
||||
comrak = "0.14.0"
|
||||
glob = "0.3.0"
|
||||
rocket = { version = "0.5.0-rc.2", features = ["json"] }
|
||||
rocket_dyn_templates = { version = "0.1.0-rc.2", features = ["tera"] }
|
||||
toml = "0.5.9"
|
||||
comrak = "0.2.5"
|
||||
glob = "0.2.11"
|
||||
Inflector = "*"
|
||||
rocket = "0.3.3"
|
||||
rocket_codegen = "0.3.3"
|
||||
serde = "1.0"
|
||||
serde_derive = "1.0"
|
||||
serde_json = "1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
serde = "1.0.145"
|
||||
serde_json = "1.0.86"
|
||||
[dependencies.rocket_contrib]
|
||||
version = "*"
|
||||
default-features = false
|
||||
features = ["json", "tera_templates"]
|
||||
|
|
39
README.md
39
README.md
|
@ -1,38 +1,37 @@
|
|||
# Rocket Pinboard
|
||||
# Wishlists
|
||||
|
||||
A [Rocket](https://rocket.rs) based web application written in Rust for
|
||||
maintaining a pinboard of notes for a family or group of friends.
|
||||
Similarly to a Wiki, all notes can be edited by everyone, as a board with
|
||||
physical notes would also allow.
|
||||
maintaining a board of wishlists for a family or group of friends.
|
||||
Similarly to a Wiki, all lists can be edited by everyone, as a board with
|
||||
physical lists would also allow.
|
||||
|
||||
## Building
|
||||
|
||||
Because Rocket still requires a nightly version of Rust, Rocket Pinboard
|
||||
also requires it. The application has been tested with
|
||||
`nightly-2018-01-13`.
|
||||
Because Rocket still requires a nightly version of Rust, Wishlist also
|
||||
requires it. The application has been tested with `nightly-2017-12-21`.
|
||||
|
||||
Rocket Pinboard uses a customized Bootstrap-style, to compile this, use the
|
||||
Ruby implementation of Sass:
|
||||
Wishlists uses a customized Bootstrap-style, to compile this, the
|
||||
Ruby implementation of Sass
|
||||
|
||||
```
|
||||
$ gem install sass
|
||||
...
|
||||
$ scss static/scss/pinboard.scss:static/csss/pinboard.css
|
||||
$ scss static/scss/wishlists.scss:static/csss/wishlists.css
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
The notes are stored as plain text files (in Markdown format) under the
|
||||
`notes` subdirectory. As an administrator of the application, you should
|
||||
touch a file with the extension `.note` in this subdirectory to create
|
||||
notes. The resulting note names are the title cased version of the snake
|
||||
cased filenames. For example to create a note for "John Doe":
|
||||
The lists are stored as plain text files (in Markdown format) under the
|
||||
`lists` subdirectory. As an administrator of the application, you should
|
||||
touch a file with the extension `.list` in this subdirectory to create
|
||||
lists. The resulting list names are the title cased version of the snake
|
||||
cased filenames. For example to create a list for "John Doe":
|
||||
|
||||
```
|
||||
$ touch notes/john_doe.note
|
||||
$ touch lists/john_doe.list
|
||||
```
|
||||
|
||||
After creating new notes, start (or restart) the application and then visit:
|
||||
After creating new lists, start (or restart) the application and then visit:
|
||||
http://localhost:8000/.
|
||||
|
||||
For setting up production or staging environments, see the
|
||||
|
@ -45,13 +44,13 @@ There are still quite some things left to do. Here are some points of
|
|||
improvement for the future:
|
||||
|
||||
* Use a Rust-implementation for Sass
|
||||
* Support different sets of notes (for different environments)
|
||||
* Support different sets of lists (for different environments)
|
||||
* Get rid of hardcoded strings in the UI (for example the name of my family)
|
||||
* Add i18n to support several languages
|
||||
* Support locking a note while editing it in the client so other clients
|
||||
* Support locking a list while editing it in the client so other clients
|
||||
cannot edit it
|
||||
|
||||
## License
|
||||
|
||||
Pinboard is licensed under the MIT license
|
||||
Wishlists is licensed under the MIT license
|
||||
([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT).
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
[debug]
|
||||
[development]
|
||||
address = "0.0.0.0"
|
||||
port = 3000
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
title = "Online Pinboard"
|
203
deny.toml
203
deny.toml
|
@ -1,203 +0,0 @@
|
|||
# This template contains all of the possible sections and their default values
|
||||
|
||||
# Note that all fields that take a lint level have these possible values:
|
||||
# * deny - An error will be produced and the check will fail
|
||||
# * warn - A warning will be produced, but the check will not fail
|
||||
# * allow - No warning or error will be produced, though in some cases a note
|
||||
# will be
|
||||
|
||||
# The values provided in this template are the default values that will be used
|
||||
# when any section or field is not specified in your own configuration
|
||||
|
||||
# If 1 or more target triples (and optionally, target_features) are specified,
|
||||
# only the specified targets will be checked when running `cargo deny check`.
|
||||
# This means, if a particular package is only ever used as a target specific
|
||||
# dependency, such as, for example, the `nix` crate only being used via the
|
||||
# `target_family = "unix"` configuration, that only having windows targets in
|
||||
# this list would mean the nix crate, as well as any of its exclusive
|
||||
# dependencies not shared by any other crates, would be ignored, as the target
|
||||
# list here is effectively saying which targets you are building for.
|
||||
targets = [
|
||||
# The triple can be any string, but only the target triples built in to
|
||||
# rustc (as of 1.40) can be checked against actual config expressions
|
||||
#{ triple = "x86_64-unknown-linux-musl" },
|
||||
# You can also specify which target_features you promise are enabled for a
|
||||
# particular target. target_features are currently not validated against
|
||||
# the actual valid features supported by the target architecture.
|
||||
#{ triple = "wasm32-unknown-unknown", features = ["atomics"] },
|
||||
]
|
||||
|
||||
# This section is considered when running `cargo deny check advisories`
|
||||
# More documentation for the advisories section can be found here:
|
||||
# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html
|
||||
[advisories]
|
||||
# The path where the advisory database is cloned/fetched into
|
||||
db-path = "~/.cargo/advisory-db"
|
||||
# The url(s) of the advisory databases to use
|
||||
db-urls = ["https://github.com/rustsec/advisory-db"]
|
||||
# The lint level for security vulnerabilities
|
||||
vulnerability = "deny"
|
||||
# The lint level for unmaintained crates
|
||||
unmaintained = "warn"
|
||||
# The lint level for crates that have been yanked from their source registry
|
||||
yanked = "warn"
|
||||
# The lint level for crates with security notices. Note that as of
|
||||
# 2019-12-17 there are no security notice advisories in
|
||||
# https://github.com/rustsec/advisory-db
|
||||
notice = "warn"
|
||||
# A list of advisory IDs to ignore. Note that ignored advisories will still
|
||||
# output a note when they are encountered.
|
||||
ignore = [
|
||||
#"RUSTSEC-0000-0000",
|
||||
]
|
||||
# Threshold for security vulnerabilities, any vulnerability with a CVSS score
|
||||
# lower than the range specified will be ignored. Note that ignored advisories
|
||||
# will still output a note when they are encountered.
|
||||
# * None - CVSS Score 0.0
|
||||
# * Low - CVSS Score 0.1 - 3.9
|
||||
# * Medium - CVSS Score 4.0 - 6.9
|
||||
# * High - CVSS Score 7.0 - 8.9
|
||||
# * Critical - CVSS Score 9.0 - 10.0
|
||||
#severity-threshold =
|
||||
|
||||
# This section is considered when running `cargo deny check licenses`
|
||||
# More documentation for the licenses section can be found here:
|
||||
# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html
|
||||
[licenses]
|
||||
# The lint level for crates which do not have a detectable license
|
||||
unlicensed = "deny"
|
||||
# List of explictly allowed licenses
|
||||
# See https://spdx.org/licenses/ for list of possible licenses
|
||||
# [possible values: any SPDX 3.7 short identifier (+ optional exception)].
|
||||
allow = [
|
||||
#"MIT",
|
||||
#"Apache-2.0",
|
||||
#"Apache-2.0 WITH LLVM-exception",
|
||||
]
|
||||
# List of explictly disallowed licenses
|
||||
# See https://spdx.org/licenses/ for list of possible licenses
|
||||
# [possible values: any SPDX 3.7 short identifier (+ optional exception)].
|
||||
deny = [
|
||||
#"Nokia",
|
||||
]
|
||||
# Lint level for licenses considered copyleft
|
||||
copyleft = "allow"
|
||||
# Blanket approval or denial for OSI-approved or FSF Free/Libre licenses
|
||||
# * both - The license will be approved if it is both OSI-approved *AND* FSF
|
||||
# * either - The license will be approved if it is either OSI-approved *OR* FSF
|
||||
# * osi-only - The license will be approved if is OSI-approved *AND NOT* FSF
|
||||
# * fsf-only - The license will be approved if is FSF *AND NOT* OSI-approved
|
||||
# * neither - This predicate is ignored and the default lint level is used
|
||||
allow-osi-fsf-free = "either"
|
||||
# Lint level used when no other predicates are matched
|
||||
# 1. License isn't in the allow or deny lists
|
||||
# 2. License isn't copyleft
|
||||
# 3. License isn't OSI/FSF, or allow-osi-fsf-free = "neither"
|
||||
default = "deny"
|
||||
# The confidence threshold for detecting a license from license text.
|
||||
# The higher the value, the more closely the license text must be to the
|
||||
# canonical license text of a valid SPDX license file.
|
||||
# [possible values: any between 0.0 and 1.0].
|
||||
confidence-threshold = 0.8
|
||||
# Allow 1 or more licenses on a per-crate basis, so that particular licenses
|
||||
# aren't accepted for every possible crate as with the normal allow list
|
||||
exceptions = [
|
||||
# Each entry is the crate and version constraint, and its specific allow
|
||||
# list
|
||||
#{ allow = ["Zlib"], name = "adler32", version = "*" },
|
||||
]
|
||||
|
||||
# Some crates don't have (easily) machine readable licensing information,
|
||||
# adding a clarification entry for it allows you to manually specify the
|
||||
# licensing information
|
||||
#[[licenses.clarify]]
|
||||
# The name of the crate the clarification applies to
|
||||
#name = "ring"
|
||||
# The optional version constraint for the crate
|
||||
#version = "*"
|
||||
# The SPDX expression for the license requirements of the crate
|
||||
#expression = "MIT AND ISC AND OpenSSL"
|
||||
# One or more files in the crate's source used as the "source of truth" for
|
||||
# the license expression. If the contents match, the clarification will be used
|
||||
# when running the license check, otherwise the clarification will be ignored
|
||||
# and the crate will be checked normally, which may produce warnings or errors
|
||||
# depending on the rest of your configuration
|
||||
#license-files = [
|
||||
# Each entry is a crate relative path, and the (opaque) hash of its contents
|
||||
#{ path = "LICENSE", hash = 0xbd0eed23 }
|
||||
#]
|
||||
|
||||
[licenses.private]
|
||||
# If true, ignores workspace crates that aren't published, or are only
|
||||
# published to private registries
|
||||
ignore = false
|
||||
# One or more private registries that you might publish crates to, if a crate
|
||||
# is only published to private registries, and ignore is true, the crate will
|
||||
# not have its license(s) checked
|
||||
registries = [
|
||||
#"https://sekretz.com/registry
|
||||
]
|
||||
|
||||
# This section is considered when running `cargo deny check bans`.
|
||||
# More documentation about the 'bans' section can be found here:
|
||||
# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html
|
||||
[bans]
|
||||
# Lint level for when multiple versions of the same crate are detected
|
||||
multiple-versions = "warn"
|
||||
# Lint level for when a crate version requirement is `*`
|
||||
wildcards = "allow"
|
||||
# The graph highlighting used when creating dotgraphs for crates
|
||||
# with multiple versions
|
||||
# * lowest-version - The path to the lowest versioned duplicate is highlighted
|
||||
# * simplest-path - The path to the version with the fewest edges is highlighted
|
||||
# * all - Both lowest-version and simplest-path are used
|
||||
highlight = "all"
|
||||
# List of crates that are allowed. Use with care!
|
||||
allow = [
|
||||
#{ name = "ansi_term", version = "=0.11.0" },
|
||||
]
|
||||
# List of crates to deny
|
||||
deny = [
|
||||
# Each entry the name of a crate and a version range. If version is
|
||||
# not specified, all versions will be matched.
|
||||
#{ name = "ansi_term", version = "=0.11.0" },
|
||||
#
|
||||
# Wrapper crates can optionally be specified to allow the crate when it
|
||||
# is a direct dependency of the otherwise banned crate
|
||||
#{ name = "ansi_term", version = "=0.11.0", wrappers = [] },
|
||||
]
|
||||
# Certain crates/versions that will be skipped when doing duplicate detection.
|
||||
skip = [
|
||||
#{ name = "ansi_term", version = "=0.11.0" },
|
||||
]
|
||||
# Similarly to `skip` allows you to skip certain crates during duplicate
|
||||
# detection. Unlike skip, it also includes the entire tree of transitive
|
||||
# dependencies starting at the specified crate, up to a certain depth, which is
|
||||
# by default infinite
|
||||
skip-tree = [
|
||||
#{ name = "ansi_term", version = "=0.11.0", depth = 20 },
|
||||
]
|
||||
|
||||
# This section is considered when running `cargo deny check sources`.
|
||||
# More documentation about the 'sources' section can be found here:
|
||||
# https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html
|
||||
[sources]
|
||||
# Lint level for what to happen when a crate from a crate registry that is not
|
||||
# in the allow list is encountered
|
||||
unknown-registry = "warn"
|
||||
# Lint level for what to happen when a crate from a git repository that is not
|
||||
# in the allow list is encountered
|
||||
unknown-git = "warn"
|
||||
# List of URLs for allowed crate registries. Defaults to the crates.io index
|
||||
# if not specified. If it is specified but empty, no registries are allowed.
|
||||
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||
# List of URLs for allowed Git repositories
|
||||
allow-git = []
|
||||
|
||||
[sources.allow-org]
|
||||
# 1 or more github.com organizations to allow git sources for
|
||||
github = []
|
||||
# 1 or more gitlab.com organizations to allow git sources for
|
||||
gitlab = []
|
||||
# 1 or more bitbucket.org organizations to allow git sources for
|
||||
bitbucket = []
|
|
@ -1,52 +1,47 @@
|
|||
use rocket::State;
|
||||
use rocket_contrib::Template;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rocket::{get, serde::Serialize, State};
|
||||
use rocket_dyn_templates::Template;
|
||||
|
||||
use crate::{Config, NoteStore};
|
||||
use super::super::ListStore;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
struct IndexTemplateContext<'a> {
|
||||
app_version: &'a str,
|
||||
notes: Vec<HashMap<&'a str, &'a str>>,
|
||||
title: String,
|
||||
app_version: &'a str,
|
||||
lists: Vec<HashMap<&'a str, &'a str>>
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
pub(crate) async fn index(notes: &State<NoteStore>, config: &State<Config>) -> Template {
|
||||
let notes = notes.read().unwrap();
|
||||
let mut note_kvs = vec![];
|
||||
for note in notes.iter() {
|
||||
let mut note_kv = HashMap::new();
|
||||
note_kv.insert("id", note.id.as_ref());
|
||||
note_kv.insert("name", note.name.as_ref());
|
||||
note_kvs.push(note_kv);
|
||||
fn index(lists: State<ListStore>) -> Template {
|
||||
let lists = lists.read().unwrap();
|
||||
let mut list_kvs = vec![];
|
||||
for list in lists.iter() {
|
||||
let mut list_kv = HashMap::new();
|
||||
list_kv.insert("id", list.id.as_ref());
|
||||
list_kv.insert("name", list.name.as_ref());
|
||||
list_kvs.push(list_kv);
|
||||
}
|
||||
let context = IndexTemplateContext {
|
||||
app_version: env!("CARGO_PKG_VERSION"),
|
||||
notes: note_kvs,
|
||||
title: config.title.clone(),
|
||||
lists: list_kvs
|
||||
};
|
||||
Template::render("index", &context)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rocket::{
|
||||
http::{Accept, Status},
|
||||
local::blocking::Client,
|
||||
};
|
||||
use rocket;
|
||||
use rocket::http::{Accept, Status};
|
||||
use rocket::local::Client;
|
||||
|
||||
#[test]
|
||||
fn index() {
|
||||
let client = Client::untracked(crate::build_rocket(Some("test"))).unwrap();
|
||||
let client = Client::new(rocket(Some("test"))).unwrap();
|
||||
|
||||
// Try to get the index and verify the body
|
||||
let res = client.get("/").header(Accept::HTML).dispatch();
|
||||
let mut res = client.get("/").header(Accept::HTML).dispatch();
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
|
||||
let body = res.into_string().unwrap();
|
||||
assert!(body.contains("<span id=\"note-name\">Test</span>"));
|
||||
let body = res.body_string().unwrap();
|
||||
println!("body: {}", body);
|
||||
assert!(body.contains("<span id=\"list-name\">Test</span>"));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,172 @@
|
|||
use rocket::State;
|
||||
use rocket_contrib::Json;
|
||||
use super::super::ListStore;
|
||||
use super::super::models::list::List;
|
||||
|
||||
#[get("/", format = "application/json")]
|
||||
fn index(lists: State<ListStore>) -> Option<Json<Vec<List>>> {
|
||||
let lists = lists.read().unwrap();
|
||||
Some(Json(lists.clone()))
|
||||
}
|
||||
|
||||
#[get("/<list_id>", format = "text/html")]
|
||||
fn show_html(list_id: String, lists: State<ListStore>) -> Option<String> {
|
||||
let lists = lists.read().unwrap();
|
||||
let list = lists.iter().find( |list| list.id == list_id )?;
|
||||
Some(list.to_html())
|
||||
}
|
||||
|
||||
#[get("/<list_id>", format = "application/json")]
|
||||
fn show_json(list_id: String, lists: State<ListStore>) -> Option<Json<List>> {
|
||||
let lists = lists.read().unwrap();
|
||||
let list = lists.iter().find( |list| list.id == list_id )?;
|
||||
Some(Json(list.clone()))
|
||||
}
|
||||
|
||||
#[put("/<list_id>", format = "application/json", data = "<new_list>")]
|
||||
fn update(list_id: String, new_list: Json<List>, lists: State<ListStore>) -> Option<Json<List>> {
|
||||
let mut lists = lists.write().unwrap();
|
||||
let list = lists.iter_mut().find( |list| list.id == list_id )?;
|
||||
list.update_data(&new_list.data);
|
||||
Some(Json(list.clone()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rocket;
|
||||
use rocket::http::{Accept, ContentType, Status};
|
||||
use rocket::local::Client;
|
||||
use serde_json;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn index() {
|
||||
let client = Client::new(rocket(Some("test"))).unwrap();
|
||||
|
||||
// Try to get all the lists
|
||||
let mut res = client.get("/lists").header(Accept::JSON).dispatch();
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
|
||||
let body = res.body_string().unwrap();
|
||||
let lists = serde_json::from_str::<Vec<List>>(body.as_str()).unwrap();
|
||||
assert_eq!(lists[0].id, "test");
|
||||
assert_eq!(lists[0].index, 0);
|
||||
assert_eq!(lists[0].name, "Test");
|
||||
assert_eq!(lists[0].data, "This is a test list\n\n* One\n* Two\n* Three\n");
|
||||
// The mtime field can vary, don't test for it
|
||||
// The path field is private, also don't test for it
|
||||
|
||||
// Cannot get the lists in HTML format
|
||||
let res = client.get("/lists").header(Accept::HTML).dispatch();
|
||||
assert_eq!(res.status(), Status::NotFound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_html() {
|
||||
let client = Client::new(rocket(Some("test"))).unwrap();
|
||||
|
||||
// Try to get the list and verify the body
|
||||
let mut res = client.get("/lists/test").header(Accept::HTML).dispatch();
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
|
||||
let body = res.body_string().unwrap();
|
||||
assert_eq!(body,
|
||||
r#"<p>This is a test list</p>
|
||||
<ul>
|
||||
<li>One</li>
|
||||
<li>Two</li>
|
||||
<li>Three</li>
|
||||
</ul>
|
||||
"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_json() {
|
||||
let client = Client::new(rocket(Some("test"))).unwrap();
|
||||
|
||||
// Try to get the list and verify the body
|
||||
let mut res = client.get("/lists/test").header(Accept::JSON).dispatch();
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
|
||||
let body = res.body_string().unwrap();
|
||||
let list = serde_json::from_str::<List>(body.as_str()).unwrap();
|
||||
assert_eq!(list.id, "test");
|
||||
assert_eq!(list.index, 0);
|
||||
assert_eq!(list.name, "Test");
|
||||
assert_eq!(list.data, "This is a test list\n\n* One\n* Two\n* Three\n");
|
||||
// The mtime field can vary, don't test for it
|
||||
// The path field is private, also don't test for it
|
||||
|
||||
// Try to get a list that doesn't exist
|
||||
let res = client.get("/lists/doesntexit").header(Accept::JSON).dispatch();
|
||||
assert_eq!(res.status(), Status::NotFound);
|
||||
|
||||
// FIXME: Test that there is some kind of error in the JSON
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update() {
|
||||
let client = Client::new(rocket(Some("test"))).unwrap();
|
||||
|
||||
// Try to get the list and determine what to change it to
|
||||
let mut res = client.get("/lists/updatable").header(Accept::JSON).dispatch();
|
||||
let body = res.body_string().unwrap();
|
||||
let list = serde_json::from_str::<List>(body.as_str()).unwrap();
|
||||
assert_eq!(list.data, "Some content");
|
||||
|
||||
// Try to change the list data, then verify it was changed
|
||||
let new_data = "New content";
|
||||
let mut new_json: serde_json::Value = json!({
|
||||
"id": "updatable",
|
||||
"index": 1,
|
||||
"data": new_data,
|
||||
"mtime": {
|
||||
"secs_since_epoch": 0,
|
||||
"nanos_since_epoch": 0
|
||||
},
|
||||
"name": "Updatable",
|
||||
"path": "test/lists/updatablelist"
|
||||
});
|
||||
let res = client.put("/lists/updatable")
|
||||
.header(ContentType::JSON)
|
||||
.body(new_json.to_string())
|
||||
.dispatch();
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
|
||||
let mut res = client.get("/lists/updatable")
|
||||
.header(Accept::JSON)
|
||||
.dispatch();
|
||||
let body = res.body_string().unwrap();
|
||||
let list = serde_json::from_str::<List>(body.as_str()).unwrap();
|
||||
assert_eq!(list.data, new_data);
|
||||
|
||||
// ... and change it back
|
||||
*new_json.get_mut("data").unwrap() = json!("Some content");
|
||||
let res = client.put("/lists/updatable")
|
||||
.header(ContentType::JSON)
|
||||
.body(new_json.to_string())
|
||||
.dispatch();
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
|
||||
// Try to change a list that doesn't exist
|
||||
let res = client.put("/lists/doesntexit")
|
||||
.header(ContentType::JSON)
|
||||
.body(new_json.to_string())
|
||||
.dispatch();
|
||||
assert_eq!(res.status(), Status::NotFound);
|
||||
|
||||
// Try to change a list without a proper body
|
||||
let res = client.put("/lists/updatable")
|
||||
.header(ContentType::JSON)
|
||||
.body(r#"{}"#)
|
||||
.dispatch();
|
||||
assert_eq!(res.status(), Status::BadRequest);
|
||||
|
||||
// Try to change a list without a proper type (i.e. not JSON)
|
||||
let res = client.put("/lists/updatable")
|
||||
.header(ContentType::Plain)
|
||||
.body("foo bar baz")
|
||||
.dispatch();
|
||||
assert_eq!(res.status(), Status::NotFound);
|
||||
}
|
||||
}
|
|
@ -1,2 +1,3 @@
|
|||
pub mod home;
|
||||
pub mod note;
|
||||
pub mod list;
|
||||
pub mod static_files;
|
||||
|
|
|
@ -1,195 +0,0 @@
|
|||
use rocket::{get, put, serde::json::Json, State};
|
||||
|
||||
use crate::{models::note::Note, NoteStore};
|
||||
|
||||
#[get("/", format = "application/json")]
|
||||
pub(crate) async fn index(notes: &State<NoteStore>) -> Option<Json<Vec<Note>>> {
|
||||
let notes = notes.read().unwrap();
|
||||
Some(Json(notes.clone()))
|
||||
}
|
||||
|
||||
#[get("/<note_id>", format = "text/html")]
|
||||
pub(crate) async fn show_html(note_id: String, notes: &State<NoteStore>) -> Option<String> {
|
||||
let notes = notes.read().unwrap();
|
||||
let note = notes.iter().find(|note| note.id == note_id)?;
|
||||
Some(note.to_html())
|
||||
}
|
||||
|
||||
#[get("/<note_id>", format = "application/json", rank = 2)]
|
||||
pub(crate) async fn show_json(note_id: String, notes: &State<NoteStore>) -> Option<Json<Note>> {
|
||||
let notes = notes.read().unwrap();
|
||||
let note = notes.iter().find(|note| note.id == note_id)?;
|
||||
Some(Json(note.clone()))
|
||||
}
|
||||
|
||||
#[put("/<note_id>", format = "application/json", data = "<new_note>")]
|
||||
pub(crate) async fn update(
|
||||
note_id: String,
|
||||
new_note: Json<Note>,
|
||||
notes: &State<NoteStore>,
|
||||
) -> Option<Json<Note>> {
|
||||
let mut notes = notes.write().unwrap();
|
||||
let note = notes.iter_mut().find(|note| note.id == note_id)?;
|
||||
note.update_data(&new_note.data);
|
||||
Some(Json(note.clone()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rocket::{
|
||||
self,
|
||||
http::{Accept, ContentType, Status},
|
||||
local::blocking::Client,
|
||||
};
|
||||
use serde_json::{self, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn index() {
|
||||
let client = Client::untracked(crate::build_rocket(Some("test"))).unwrap();
|
||||
|
||||
// Try to get all the notes
|
||||
let res = client.get("/notes").header(Accept::JSON).dispatch();
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
|
||||
let body = res.into_string().unwrap();
|
||||
let notes = serde_json::from_str::<Vec<Note>>(body.as_str()).unwrap();
|
||||
assert_eq!(notes[0].id, "test");
|
||||
assert_eq!(notes[0].index, 0);
|
||||
assert_eq!(notes[0].name, "Test");
|
||||
assert_eq!(
|
||||
notes[0].data,
|
||||
"This is a test note\n\n* One\n* Two\n* Three\n"
|
||||
);
|
||||
// The mtime field can vary, don't test for it
|
||||
// The path field is private, also don't test for it
|
||||
|
||||
// Cannot get the notes in HTML format
|
||||
let res = client.get("/notes").header(Accept::HTML).dispatch();
|
||||
assert_eq!(res.status(), Status::NotFound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_html() {
|
||||
let client = Client::untracked(crate::build_rocket(Some("test"))).unwrap();
|
||||
|
||||
// Try to get the note and verify the body
|
||||
let res = client.get("/notes/test").header(Accept::HTML).dispatch();
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
|
||||
let body = res.into_string().unwrap();
|
||||
assert_eq!(
|
||||
body,
|
||||
r#"<p>This is a test note</p>
|
||||
<ul>
|
||||
<li>One</li>
|
||||
<li>Two</li>
|
||||
<li>Three</li>
|
||||
</ul>
|
||||
"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_json() {
|
||||
let client = Client::untracked(crate::build_rocket(Some("test"))).unwrap();
|
||||
|
||||
// Try to get the note and verify the body
|
||||
let res = client.get("/notes/test").header(Accept::JSON).dispatch();
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
|
||||
let body = res.into_string().unwrap();
|
||||
let note = serde_json::from_str::<Note>(body.as_str()).unwrap();
|
||||
assert_eq!(note.id, "test");
|
||||
assert_eq!(note.index, 0);
|
||||
assert_eq!(note.name, "Test");
|
||||
assert_eq!(note.data, "This is a test note\n\n* One\n* Two\n* Three\n");
|
||||
// The mtime field can vary, don't test for it
|
||||
// The path field is private, also don't test for it
|
||||
|
||||
// Try to get a note that doesn't exist
|
||||
let res = client
|
||||
.get("/notes/doesntexit")
|
||||
.header(Accept::JSON)
|
||||
.dispatch();
|
||||
assert_eq!(res.status(), Status::NotFound);
|
||||
|
||||
// FIXME: Test that there is some kind of error in the JSON
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update() {
|
||||
let client = Client::untracked(crate::build_rocket(Some("test"))).unwrap();
|
||||
|
||||
// Try to get the note and determine what to change it to
|
||||
let res = client
|
||||
.get("/notes/updatable")
|
||||
.header(Accept::JSON)
|
||||
.dispatch();
|
||||
let body = res.into_string().unwrap();
|
||||
let note = serde_json::from_str::<Note>(body.as_str()).unwrap();
|
||||
assert_eq!(note.data, "Some content");
|
||||
|
||||
// Try to change the note data, then verify it was changed
|
||||
let new_data = "New content";
|
||||
let mut new_json: serde_json::Value = json!({
|
||||
"id": "updatable",
|
||||
"index": 1,
|
||||
"data": new_data,
|
||||
"mtime": {
|
||||
"secs_since_epoch": 0,
|
||||
"nanos_since_epoch": 0
|
||||
},
|
||||
"name": "Updatable",
|
||||
"path": "test/notes/updatablenote"
|
||||
});
|
||||
let res = client
|
||||
.put("/notes/updatable")
|
||||
.header(ContentType::JSON)
|
||||
.body(new_json.to_string())
|
||||
.dispatch();
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
|
||||
let res = client
|
||||
.get("/notes/updatable")
|
||||
.header(Accept::JSON)
|
||||
.dispatch();
|
||||
let body = res.into_string().unwrap();
|
||||
let note = serde_json::from_str::<Note>(body.as_str()).unwrap();
|
||||
assert_eq!(note.data, new_data);
|
||||
|
||||
// ... and change it back
|
||||
*new_json.get_mut("data").unwrap() = json!("Some content");
|
||||
let res = client
|
||||
.put("/notes/updatable")
|
||||
.header(ContentType::JSON)
|
||||
.body(new_json.to_string())
|
||||
.dispatch();
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
|
||||
// Try to change a note that doesn't exist
|
||||
let res = client
|
||||
.put("/notes/doesntexit")
|
||||
.header(ContentType::JSON)
|
||||
.body(new_json.to_string())
|
||||
.dispatch();
|
||||
assert_eq!(res.status(), Status::NotFound);
|
||||
|
||||
// Try to change a note without a proper body
|
||||
let res = client
|
||||
.put("/notes/updatable")
|
||||
.header(ContentType::JSON)
|
||||
.body(r#"{}"#)
|
||||
.dispatch();
|
||||
assert_eq!(res.status().code, 422);
|
||||
|
||||
// Try to change a note without a proper type (i.e. not JSON)
|
||||
let res = client
|
||||
.put("/notes/updatable")
|
||||
.header(ContentType::Plain)
|
||||
.body("foo bar baz")
|
||||
.dispatch();
|
||||
assert_eq!(res.status(), Status::NotFound);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
use std::path::{Path,PathBuf};
|
||||
use rocket::response::NamedFile;
|
||||
|
||||
#[get("/<path..>", rank = 5)]
|
||||
fn all(path: PathBuf) -> Option<NamedFile> {
|
||||
NamedFile::open(Path::new("static/").join(path)).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rocket;
|
||||
use rocket::http::Status;
|
||||
use rocket::local::Client;
|
||||
|
||||
#[test]
|
||||
fn all() {
|
||||
let client = Client::new(rocket(Some("test"))).unwrap();
|
||||
|
||||
// Try to get the main JavaScript file
|
||||
let res = client.get("/js/wishlists.js").dispatch();
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
}
|
||||
}
|
73
src/main.rs
73
src/main.rs
|
@ -1,57 +1,34 @@
|
|||
#![feature(plugin)]
|
||||
#![plugin(rocket_codegen)]
|
||||
|
||||
extern crate comrak;
|
||||
extern crate glob;
|
||||
extern crate inflector;
|
||||
extern crate rocket;
|
||||
extern crate rocket_contrib;
|
||||
#[macro_use] extern crate serde_derive;
|
||||
#[macro_use] extern crate serde_json;
|
||||
|
||||
mod handlers;
|
||||
mod models;
|
||||
|
||||
use rocket::Rocket;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use rocket::{
|
||||
fs::{relative, FileServer},
|
||||
routes,
|
||||
serde::Deserialize,
|
||||
Build, Rocket,
|
||||
};
|
||||
use rocket_dyn_templates::Template;
|
||||
type ListStore = RwLock<Vec<models::list::List>>;
|
||||
|
||||
type NoteStore = RwLock<Vec<models::note::Note>>;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct Config {
|
||||
title: String,
|
||||
fn rocket(lists_path: Option<&str>) -> Rocket {
|
||||
let lists = models::list::List::load_all(lists_path);
|
||||
rocket::ignite()
|
||||
.manage(RwLock::new(lists))
|
||||
.mount("/", routes![handlers::home::index,
|
||||
handlers::static_files::all])
|
||||
.mount("/lists", routes![handlers::list::index,
|
||||
handlers::list::show_html, handlers::list::show_json,
|
||||
handlers::list::update])
|
||||
.attach(rocket_contrib::Template::fairing())
|
||||
}
|
||||
|
||||
fn build_rocket(notes_path: Option<&str>) -> Rocket<Build> {
|
||||
use std::{fs::File, io::prelude::*, path::Path};
|
||||
|
||||
let mut config_data = String::new();
|
||||
let config_file_name = Path::new(env!("CARGO_MANIFEST_DIR")).join("config.toml");
|
||||
let mut config_file =
|
||||
File::open(config_file_name).expect("Cannot find config file: config.toml");
|
||||
config_file
|
||||
.read_to_string(&mut config_data)
|
||||
.expect("Cannot read config file: config.toml");
|
||||
let config: Config =
|
||||
toml::from_str(&config_data).expect("Cannot parse config file: config.toml");
|
||||
|
||||
let notes = models::note::Note::load_all(notes_path);
|
||||
|
||||
rocket::build()
|
||||
.manage(RwLock::new(notes))
|
||||
.manage(config)
|
||||
.mount("/", FileServer::from(relative!("static")))
|
||||
.mount("/", routes![handlers::home::index])
|
||||
.mount(
|
||||
"/notes",
|
||||
routes![
|
||||
handlers::note::index,
|
||||
handlers::note::show_html,
|
||||
handlers::note::show_json,
|
||||
handlers::note::update
|
||||
],
|
||||
)
|
||||
.attach(Template::fairing())
|
||||
}
|
||||
|
||||
#[rocket::launch]
|
||||
fn rocket() -> _ {
|
||||
build_rocket(None)
|
||||
fn main() {
|
||||
rocket(None).launch();
|
||||
}
|
||||
|
|
|
@ -0,0 +1,137 @@
|
|||
use comrak;
|
||||
use glob::glob;
|
||||
use inflector::Inflector;
|
||||
use std::fs::File;
|
||||
use std::io::prelude::*;
|
||||
use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
/// Structure for representing a wish list
|
||||
pub struct List {
|
||||
/// The ID of the list (unique string)
|
||||
pub id: String,
|
||||
/// The index of the list (unique number)
|
||||
pub index: i8,
|
||||
/// The raw list data
|
||||
pub data: String,
|
||||
/// The time the list was last modified
|
||||
pub mtime: SystemTime,
|
||||
/// The name of the list, i.e. the person it is for
|
||||
pub name: String,
|
||||
/// The path to the list file
|
||||
path: PathBuf
|
||||
}
|
||||
|
||||
impl List {
|
||||
pub fn to_html(&self) -> String {
|
||||
let mut options = comrak::ComrakOptions::default();
|
||||
options.ext_strikethrough = true;
|
||||
options.ext_tagfilter = true;
|
||||
options.ext_autolink = true;
|
||||
options.ext_tasklist = true;
|
||||
|
||||
comrak::markdown_to_html(&self.data, &options)
|
||||
}
|
||||
|
||||
pub fn update_data(&mut self, data : &String) {
|
||||
let mut file = File::create(&self.path)
|
||||
.expect(&format!("Cannot open list file {}",
|
||||
self.path.to_str().unwrap()));
|
||||
file.write_all(data.as_bytes())
|
||||
.expect(&format!("Cannot write list file {}",
|
||||
self.path.to_str().unwrap()));
|
||||
|
||||
self.data = data.clone();
|
||||
let metadata = file.metadata().unwrap();
|
||||
self.mtime = metadata.modified().unwrap();
|
||||
}
|
||||
|
||||
pub fn load_all(list_path: Option<&str>) -> Vec<Self> {
|
||||
let mut lists : Vec<List> = vec![];
|
||||
let mut index = 0;
|
||||
let path_glob = match list_path {
|
||||
Some(dir) => format!("{}/lists/*.list", dir),
|
||||
None => format!("lists/*.list")
|
||||
};
|
||||
for entry in glob(path_glob.as_str()).unwrap().filter_map(Result::ok) {
|
||||
let file_name = entry.file_name().unwrap().to_str().unwrap();
|
||||
let name = match file_name.find('.') {
|
||||
Some(index) => &file_name[0..index],
|
||||
None => "unknown"
|
||||
};
|
||||
let mut data = String::new();
|
||||
let mut file = File::open(&entry)
|
||||
.expect(&format!("Cannot open list file {}", file_name));
|
||||
file.read_to_string(&mut data)
|
||||
.expect(&format!("Cannot read list file {}", file_name));
|
||||
let metadata = file.metadata()
|
||||
.expect(&format!("Cannot get metadata of list file {}", file_name));
|
||||
|
||||
let mut list = List {
|
||||
id: String::from(name),
|
||||
index : index,
|
||||
data: data,
|
||||
mtime: metadata.modified().unwrap(),
|
||||
name: String::from(name).to_title_case(),
|
||||
path: entry.clone()
|
||||
};
|
||||
lists.push(list);
|
||||
index += 1;
|
||||
}
|
||||
lists
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn loads_all_lists() {
|
||||
let lists = List::load_all(Some("test"));
|
||||
let list_ids: Vec<&str> = lists.iter()
|
||||
.map(|list| list.id.as_ref()).collect();
|
||||
assert_eq!(list_ids, vec!["test", "updatable"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_to_html() {
|
||||
let lists = List::load_all(Some("test"));
|
||||
let list = lists.iter()
|
||||
.find(|list| list.id == "test")
|
||||
.unwrap();
|
||||
assert_eq!(list.to_html(), r#"<p>This is a test list</p>
|
||||
<ul>
|
||||
<li>One</li>
|
||||
<li>Two</li>
|
||||
<li>Three</li>
|
||||
</ul>
|
||||
"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updates_data() {
|
||||
let mut lists = List::load_all(Some("test"));
|
||||
let list = lists.iter_mut()
|
||||
.find(|list| list.id == "updatable")
|
||||
.unwrap();
|
||||
assert_eq!(list.data, "Some content");
|
||||
|
||||
// Update the data van verify it has changed
|
||||
let new_data = "New content";
|
||||
list.update_data(&String::from(new_data));
|
||||
assert_eq!(list.data, new_data);
|
||||
|
||||
// Verify that the data is written to the file of the list by
|
||||
// loading them again
|
||||
let mut lists = List::load_all(Some("test"));
|
||||
let list = lists.iter_mut()
|
||||
.find(|list| list.id == "updatable")
|
||||
.unwrap();
|
||||
assert_eq!(list.data, new_data);
|
||||
|
||||
// ... and change it back again
|
||||
list.update_data(&String::from("Some content"));
|
||||
}
|
||||
}
|
|
@ -1 +1 @@
|
|||
pub mod note;
|
||||
pub mod list;
|
||||
|
|
|
@ -1,141 +0,0 @@
|
|||
use std::{fs::File, io::prelude::*, path::PathBuf, time::SystemTime};
|
||||
|
||||
use comrak;
|
||||
use glob::glob;
|
||||
use inflector::Inflector;
|
||||
use rocket::serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
/// Structure for representing a wish note
|
||||
pub struct Note {
|
||||
/// The ID of the note (unique string)
|
||||
pub id: String,
|
||||
/// The index of the note (unique number)
|
||||
pub index: usize,
|
||||
/// The raw note data
|
||||
pub data: String,
|
||||
/// The time the note was last modified
|
||||
pub mtime: SystemTime,
|
||||
/// The name of the note, i.e. the person it is for
|
||||
pub name: String,
|
||||
/// The path to the note file
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Note {
|
||||
pub fn to_html(&self) -> String {
|
||||
let mut options = comrak::ComrakOptions::default();
|
||||
options.extension.strikethrough = true;
|
||||
options.extension.tagfilter = true;
|
||||
options.extension.autolink = true;
|
||||
options.extension.tasklist = true;
|
||||
|
||||
comrak::markdown_to_html(&self.data, &options)
|
||||
}
|
||||
|
||||
pub fn update_data(&mut self, data: &String) {
|
||||
let path = &self.path;
|
||||
let mut file = File::create(path)
|
||||
.unwrap_or_else(|_| panic!("Cannot open note file: {}", path.display()));
|
||||
file.write_all(data.as_bytes())
|
||||
.unwrap_or_else(|_| panic!("Cannot write note file: {}", path.display()));
|
||||
|
||||
self.data = data.clone();
|
||||
let metadata = file.metadata().unwrap();
|
||||
self.mtime = metadata.modified().unwrap();
|
||||
}
|
||||
|
||||
pub fn load_all(note_path: Option<&str>) -> Vec<Self> {
|
||||
let mut notes: Vec<Note> = vec![];
|
||||
let path_glob = match note_path {
|
||||
Some(dir) => format!("{}/notes/*.note", dir),
|
||||
None => String::from("notes/*.note"),
|
||||
};
|
||||
for (index, entry) in glob(path_glob.as_str())
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.enumerate()
|
||||
{
|
||||
let file_name = entry.file_name().unwrap().to_str().unwrap();
|
||||
let name = match file_name.find('.') {
|
||||
Some(index) => &file_name[0..index],
|
||||
None => "unknown",
|
||||
};
|
||||
let mut data = String::new();
|
||||
let mut file = File::open(&entry)
|
||||
.unwrap_or_else(|e| panic!("Cannot open note file {file_name}: {e}"));
|
||||
file.read_to_string(&mut data)
|
||||
.unwrap_or_else(|e| panic!("Cannot read note file {file_name}: {e}"));
|
||||
let metadata = file
|
||||
.metadata()
|
||||
.unwrap_or_else(|e| panic!("Cannot get metadata of note file {file_name}: {e}"));
|
||||
|
||||
let note = Note {
|
||||
id: String::from(name),
|
||||
index,
|
||||
data,
|
||||
mtime: metadata.modified().unwrap(),
|
||||
name: String::from(name).to_title_case(),
|
||||
path: entry.clone(),
|
||||
};
|
||||
notes.push(note);
|
||||
}
|
||||
notes
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn loads_all_notes() {
|
||||
let notes = Note::load_all(Some("test"));
|
||||
let note_ids: Vec<&str> = notes.iter().map(|note| note.id.as_ref()).collect();
|
||||
assert_eq!(note_ids, vec!["test", "updatable"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_to_html() {
|
||||
let notes = Note::load_all(Some("test"));
|
||||
let note = notes.iter().find(|note| note.id == "test").unwrap();
|
||||
assert_eq!(
|
||||
note.to_html(),
|
||||
r#"<p>This is a test note</p>
|
||||
<ul>
|
||||
<li>One</li>
|
||||
<li>Two</li>
|
||||
<li>Three</li>
|
||||
</ul>
|
||||
"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updates_data() {
|
||||
let mut notes = Note::load_all(Some("test"));
|
||||
let note = notes
|
||||
.iter_mut()
|
||||
.find(|note| note.id == "updatable")
|
||||
.unwrap();
|
||||
assert_eq!(note.data, "Some content");
|
||||
|
||||
// Update the data van verify it has changed
|
||||
let new_data = "New content";
|
||||
note.update_data(&String::from(new_data));
|
||||
assert_eq!(note.data, new_data);
|
||||
|
||||
// Verify that the data is written to the file of the note by
|
||||
// loading them again
|
||||
let mut notes = Note::load_all(Some("test"));
|
||||
let note = notes
|
||||
.iter_mut()
|
||||
.find(|note| note.id == "updatable")
|
||||
.unwrap();
|
||||
assert_eq!(note.data, new_data);
|
||||
|
||||
// ... and change it back again
|
||||
note.update_data(&String::from("Some content"));
|
||||
}
|
||||
}
|
|
@ -6496,4 +6496,4 @@ footer {
|
|||
color: inherit;
|
||||
text-decoration: underline; }
|
||||
|
||||
/*# sourceMappingURL=pinboard.css.map */
|
||||
/*# sourceMappingURL=wishlists.css.map */
|
File diff suppressed because one or more lines are too long
|
@ -1,225 +0,0 @@
|
|||
var notes;
|
||||
|
||||
var bgColors = ["info", "primary", "danger", "success", "warning", "secondary"];
|
||||
var textColors = ["light", "light", "light", "light", "dark", "dark" ];
|
||||
|
||||
function getUrlNoteId() {
|
||||
var hash = window.location.hash;
|
||||
if (hash.length <= 1) {
|
||||
return undefined;
|
||||
} else {
|
||||
return hash.substr(1, hash.length);
|
||||
}
|
||||
}
|
||||
|
||||
function showErrorDialog(title, html, buttons) {
|
||||
var errorDialog = $("#errorDialog");
|
||||
errorDialog.find('.modal-title').text(title);
|
||||
errorDialog.find('.modal-body').html(html);
|
||||
if (buttons) {
|
||||
errorDialog.find('.modal-footer').show();
|
||||
} else {
|
||||
errorDialog.find('.modal-footer').hide();
|
||||
}
|
||||
errorDialog.modal();
|
||||
}
|
||||
|
||||
function initNotes() {
|
||||
$.ajax({
|
||||
url: '/notes',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
}).done(function(allNotes) {
|
||||
notes = allNotes;
|
||||
notes.forEach(updateNote);
|
||||
|
||||
var curNoteId = getUrlNoteId();
|
||||
selectNote(curNoteId);
|
||||
}).fail(function(jqXHR, textMsg, error) {
|
||||
showErrorDialog("Laden mislukt!",
|
||||
"<p>Kan de lijsten niet laden!</p>" +
|
||||
"<p>Probeer eventueel de pagina te verversen</p>" +
|
||||
"<p class='pt-2'><small><i>(Technische foutmelding: " + error +
|
||||
" (" + textMsg + "))<i></small></p>",
|
||||
false);
|
||||
});
|
||||
}
|
||||
|
||||
function getNoteElem(noteId) {
|
||||
return $('.note[data-note-id="' + noteId + '"]');
|
||||
}
|
||||
|
||||
function selectNote(noteId) {
|
||||
if (noteId) {
|
||||
console.debug("Selecting note " + noteId);
|
||||
notes.forEach(function(note) {
|
||||
var noteElem = getNoteElem(note.id);
|
||||
if (note.id == noteId) {
|
||||
noteElem.show(200);
|
||||
} else {
|
||||
noteElem.hide(200);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.debug("Showing all notes");
|
||||
notes.forEach(function(note) {
|
||||
var noteElem = getNoteElem(note.id);
|
||||
noteElem.show(200);
|
||||
});
|
||||
}
|
||||
|
||||
$("#navbarNav").collapse('hide');
|
||||
}
|
||||
|
||||
function updateNote(note) {
|
||||
var noteElem = $('.note[data-note-id="' + note.id + '"]');
|
||||
noteElem.addClass("bg-" + bgColors[note.index])
|
||||
.addClass("text-" + textColors[note.index]);
|
||||
$('.note-name', noteElem).text(note.name);
|
||||
mtime = new Date(note.mtime.secs_since_epoch * 1000);
|
||||
$('.note-updated-at', noteElem).text('Laatste aanpassing op ' +
|
||||
mtime.toLocaleDateString("nl") + ' om ' + mtime.toLocaleTimeString("nl"));
|
||||
$('.note-data', noteElem).val(note.data);
|
||||
|
||||
updateNoteHTML(noteElem);
|
||||
|
||||
disableNoteEditMode(noteElem);
|
||||
}
|
||||
|
||||
function updateNoteHTML(noteElem) {
|
||||
var noteId = noteElem.data("note-id");
|
||||
var noteHtmlElem = $('.note-html', noteElem)
|
||||
|
||||
$.ajax({
|
||||
url: '/notes/' + noteId,
|
||||
headers: { 'Accept': 'text/html' }
|
||||
}).done(function(html) {
|
||||
noteHtmlElem.html(html);
|
||||
$("ul > li", noteHtmlElem).has('input[type="checkbox"]')
|
||||
.parent()
|
||||
.addClass("tasknote");
|
||||
if (noteHtmlElem.find('hr').length) {
|
||||
noteHtmlElem.find('hr')
|
||||
.nextAll()
|
||||
.wrapAll('<div class="collapse note-more"/>')
|
||||
noteHtmlElem.append('<div class="row justify-content-center">' +
|
||||
'<button class="btn btn-sm btn-light text-dark note-more-toggle" '+
|
||||
'data-toggle="collapse" ' +
|
||||
'data-target=".note[data-note-id=\'' + noteId + '\'] .note-more">' +
|
||||
'Meer…</button>' +
|
||||
'</div>');
|
||||
|
||||
var noteMoreButton = noteHtmlElem.find(".note-more-toggle");
|
||||
noteHtmlElem.find('.note-more')
|
||||
.on('shown.bs.collapse', function() {
|
||||
noteMoreButton.text('Minder…');
|
||||
}).on('hidden.bs.collapse', function() {
|
||||
noteMoreButton.text('Meer…');
|
||||
});
|
||||
}
|
||||
}).fail(function(html, textMsg, error) {
|
||||
noteHtmlElem.html("<h3><i>Kan lijst niet tonen!</i></h3>" +
|
||||
"<p class='pt-2'><small><i>(Technische foutmelding: " + error +
|
||||
" (" + textMsg + "))<i></small></p>");
|
||||
});
|
||||
}
|
||||
|
||||
function saveNoteChanges(noteElem) {
|
||||
var noteId = noteElem.data("note-id");
|
||||
var note = notes.find(function(note) { return note.id == noteId });
|
||||
var old_data = note.data;
|
||||
note.data = $('.note-data', noteElem).val();
|
||||
|
||||
$.ajax({
|
||||
method: 'PUT',
|
||||
url: '/notes/' + noteId,
|
||||
headers: { 'Accept': 'application/json',
|
||||
'Content-Type': 'application/json' },
|
||||
data: JSON.stringify(note)
|
||||
}).done(updateNote)
|
||||
.fail(function(jqXHR, textMsg, error) {
|
||||
note.data = old_data;
|
||||
showErrorDialog("Opslaan mislukt!",
|
||||
"<p>Kan de lijst niet opslaan! Probeer later nog eens of " +
|
||||
"annuleer de bewerking.</p>" +
|
||||
"<p class='pt-2'><small><i>(Technische foutmelding: " + error +
|
||||
" (" + textMsg + "))<i></small></p>",
|
||||
true);
|
||||
});
|
||||
}
|
||||
|
||||
function revertNoteChanges(noteElem) {
|
||||
var noteId = noteElem.data("note-id");
|
||||
var note = notes.find(function(note) { return note.id == noteId });
|
||||
|
||||
disableNoteEditMode(noteElem);
|
||||
|
||||
$('.note-data', noteElem).val(note.data);
|
||||
}
|
||||
|
||||
function disableNoteEditMode(noteElem) {
|
||||
$('.note-data', noteElem).hide(200)
|
||||
.popover('hide');
|
||||
$('.note-html', noteElem).show(200);
|
||||
$('.note-edit-buttons', noteElem).hide(200);
|
||||
$('.note-edit-mode-buttons', noteElem).show(200);
|
||||
$('.note-edit-help', noteElem).removeClass('active')
|
||||
.popover('hide');
|
||||
}
|
||||
|
||||
function enableNoteEditMode(noteElem) {
|
||||
$('.note-data', noteElem).show(200);
|
||||
$('.note-html', noteElem).hide(200);
|
||||
$('.note-edit-mode-buttons', noteElem).hide(200);
|
||||
$('.note-edit-buttons', noteElem).show(200);
|
||||
}
|
||||
|
||||
$(function() {
|
||||
$('.notes-refresh').on('click', function() {
|
||||
if ($(".note-data:visible").length) {
|
||||
showErrorDialog("Kan niet verversen tijdens bewerken!",
|
||||
"<p>Het is niet mogelijk om de lijsten te verversen als er " +
|
||||
"op dit moment een lijst bewerkt wordt.</p>" +
|
||||
"<p>Sla te lijst die bewerkt wordt eerst op of annuleer de " +
|
||||
"bewerking.",
|
||||
true);
|
||||
} else {
|
||||
initNotes();
|
||||
}
|
||||
});
|
||||
|
||||
$('.note-cancel-button').on('click', function() {
|
||||
var noteElem = $(this).parents(".note");
|
||||
console.debug("Cancelling the edit of note " + noteElem.data("note-id"));
|
||||
revertNoteChanges(noteElem);
|
||||
});
|
||||
|
||||
$('.note-edit-button').on('click', function() {
|
||||
var noteElem = $(this).parents(".note");
|
||||
console.debug("Going to edit note " + noteElem.data("note-id"));
|
||||
enableNoteEditMode(noteElem);
|
||||
});
|
||||
|
||||
$('.note-edit-help').on('click', function() {
|
||||
var noteElem = $(this).parents(".note");
|
||||
var noteDataElem = $('.note-data', noteElem);
|
||||
$(this).toggleClass('active')
|
||||
noteDataElem.popover('toggle');
|
||||
});
|
||||
|
||||
$('.note-save-button').on('click', function() {
|
||||
var noteElem = $(this).parents(".note");
|
||||
console.debug("Saving the changes for note " + noteElem.data("note-id"));
|
||||
saveNoteChanges(noteElem);
|
||||
});
|
||||
|
||||
$('.note-select').on('click', function() {
|
||||
noteId = $(this).data('note-id');
|
||||
selectNote(noteId);
|
||||
});
|
||||
|
||||
initNotes();
|
||||
$('textarea').popover({
|
||||
html: true,
|
||||
trigger: 'manual'
|
||||
});
|
||||
});
|
|
@ -0,0 +1,226 @@
|
|||
var lists;
|
||||
|
||||
var bgColors = ["info", "primary", "danger", "success", "warning", "secondary"];
|
||||
var textColors = ["light", "light", "light", "light", "dark", "dark" ];
|
||||
|
||||
function getUrlListId() {
|
||||
var hash = window.location.hash;
|
||||
if (hash.length <= 1) {
|
||||
return undefined;
|
||||
} else {
|
||||
return hash.substr(1, hash.length);
|
||||
}
|
||||
}
|
||||
|
||||
function showErrorDialog(title, html, buttons) {
|
||||
var errorDialog = $("#errorDialog");
|
||||
errorDialog.find('.modal-title').text(title);
|
||||
errorDialog.find('.modal-body').html(html);
|
||||
if (buttons) {
|
||||
errorDialog.find('.modal-footer').show();
|
||||
} else {
|
||||
errorDialog.find('.modal-footer').hide();
|
||||
}
|
||||
errorDialog.modal();
|
||||
}
|
||||
|
||||
function initLists() {
|
||||
$.ajax({
|
||||
url: '/lists',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
}).done(function(allLists) {
|
||||
lists = allLists;
|
||||
lists.forEach(updateList);
|
||||
|
||||
var curListId = getUrlListId();
|
||||
selectList(curListId);
|
||||
}).fail(function(jqXHR, textMsg, error) {
|
||||
showErrorDialog("Laden mislukt!",
|
||||
"<p>Kan de lijsten niet laden!</p>" +
|
||||
"<p>Probeer eventueel de pagina te verversen</p>" +
|
||||
"<p class='pt-2'><small><i>(Technische foutmelding: " + error +
|
||||
" (" + textMsg + "))<i></small></p>",
|
||||
false);
|
||||
});
|
||||
}
|
||||
|
||||
function getListElem(listId) {
|
||||
return $('.list[data-list-id="' + listId + '"]');
|
||||
}
|
||||
|
||||
function selectList(listId) {
|
||||
if (listId) {
|
||||
console.debug("Selecting list " + listId);
|
||||
lists.forEach(function(list) {
|
||||
var listElem = getListElem(list.id);
|
||||
if (list.id == listId) {
|
||||
listElem.show(200);
|
||||
} else {
|
||||
listElem.hide(200);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.debug("Showing all lists");
|
||||
lists.forEach(function(list) {
|
||||
var listElem = getListElem(list.id);
|
||||
listElem.show(200);
|
||||
});
|
||||
}
|
||||
|
||||
$("#navbarNav").collapse('hide');
|
||||
}
|
||||
|
||||
function updateList(list) {
|
||||
var listElem = $('.list[data-list-id="' + list.id + '"]');
|
||||
listElem.addClass("bg-" + bgColors[list.index])
|
||||
.addClass("text-" + textColors[list.index]);
|
||||
$('.list-name', listElem).text(list.name);
|
||||
mtime = new Date(list.mtime.secs_since_epoch * 1000);
|
||||
$('.list-updated-at', listElem).text('Laatste aanpassing op ' +
|
||||
mtime.toLocaleDateString("nl") + ' om ' + mtime.toLocaleTimeString("nl"));
|
||||
$('.list-data', listElem).val(list.data);
|
||||
|
||||
updateListHTML(listElem);
|
||||
|
||||
disableListEditMode(listElem);
|
||||
}
|
||||
|
||||
function updateListHTML(listElem) {
|
||||
var listId = listElem.data("list-id");
|
||||
var listHtmlElem = $('.list-html', listElem)
|
||||
|
||||
$.ajax({
|
||||
url: '/lists/' + listId,
|
||||
headers: { 'Accept': 'text/html' }
|
||||
}).done(function(html) {
|
||||
listHtmlElem.html(html);
|
||||
$("ul > li", listHtmlElem).has('input[type="checkbox"]')
|
||||
.parent()
|
||||
.addClass("tasklist");
|
||||
if (listHtmlElem.find('hr').length) {
|
||||
listHtmlElem.find('hr')
|
||||
.nextAll()
|
||||
.wrapAll('<div class="collapse list-more"/>')
|
||||
listHtmlElem.append('<div class="row justify-content-center">' +
|
||||
'<button class="btn btn-sm btn-light text-dark list-more-toggle" '+
|
||||
'data-toggle="collapse" ' +
|
||||
'data-target=".list[data-list-id=\'' + listId + '\'] .list-more">' +
|
||||
'Meer…</button>' +
|
||||
'</div>');
|
||||
|
||||
var listMoreButton = listHtmlElem.find(".list-more-toggle");
|
||||
console.debug(listMoreButton);
|
||||
listHtmlElem.find('.list-more')
|
||||
.on('shown.bs.collapse', function() {
|
||||
listMoreButton.text('Minder…');
|
||||
}).on('hidden.bs.collapse', function() {
|
||||
listMoreButton.text('Meer…');
|
||||
});
|
||||
}
|
||||
}).fail(function(html, textMsg, error) {
|
||||
listHtmlElem.html("<h3><i>Kan lijst niet tonen!</i></h3>" +
|
||||
"<p class='pt-2'><small><i>(Technische foutmelding: " + error +
|
||||
" (" + textMsg + "))<i></small></p>");
|
||||
});
|
||||
}
|
||||
|
||||
function saveListChanges(listElem) {
|
||||
var listId = listElem.data("list-id");
|
||||
var list = lists.find(function(list) { return list.id == listId });
|
||||
var old_data = list.data;
|
||||
list.data = $('.list-data', listElem).val();
|
||||
|
||||
$.ajax({
|
||||
method: 'PUT',
|
||||
url: '/lists/' + listId,
|
||||
headers: { 'Accept': 'application/json',
|
||||
'Content-Type': 'application/json' },
|
||||
data: JSON.stringify(list)
|
||||
}).done(updateList)
|
||||
.fail(function(jqXHR, textMsg, error) {
|
||||
list.data = old_data;
|
||||
showErrorDialog("Opslaan mislukt!",
|
||||
"<p>Kan de lijst niet opslaan! Probeer later nog eens of " +
|
||||
"annuleer de bewerking.</p>" +
|
||||
"<p class='pt-2'><small><i>(Technische foutmelding: " + error +
|
||||
" (" + textMsg + "))<i></small></p>",
|
||||
true);
|
||||
});
|
||||
}
|
||||
|
||||
function revertListChanges(listElem) {
|
||||
var listId = listElem.data("list-id");
|
||||
var list = lists.find(function(list) { return list.id == listId });
|
||||
|
||||
disableListEditMode(listElem);
|
||||
|
||||
$('.list-data', listElem).val(list.data);
|
||||
}
|
||||
|
||||
function disableListEditMode(listElem) {
|
||||
$('.list-data', listElem).hide(200)
|
||||
.popover('hide');
|
||||
$('.list-html', listElem).show(200);
|
||||
$('.list-edit-buttons', listElem).hide(200);
|
||||
$('.list-edit-mode-buttons', listElem).show(200);
|
||||
$('.list-edit-help', listElem).removeClass('active')
|
||||
.popover('hide');
|
||||
}
|
||||
|
||||
function enableListEditMode(listElem) {
|
||||
$('.list-data', listElem).show(200);
|
||||
$('.list-html', listElem).hide(200);
|
||||
$('.list-edit-mode-buttons', listElem).hide(200);
|
||||
$('.list-edit-buttons', listElem).show(200);
|
||||
}
|
||||
|
||||
$(function() {
|
||||
$('.lists-refresh').on('click', function() {
|
||||
if ($(".list-data:visible").length) {
|
||||
showErrorDialog("Kan niet verversen tijdens bewerken!",
|
||||
"<p>Het is niet mogelijk om de lijsten te verversen als er " +
|
||||
"op dit moment een lijst bewerkt wordt.</p>" +
|
||||
"<p>Sla te lijst die bewerkt wordt eerst op of annuleer de " +
|
||||
"bewerking.",
|
||||
true);
|
||||
} else {
|
||||
initLists();
|
||||
}
|
||||
});
|
||||
|
||||
$('.list-cancel-button').on('click', function() {
|
||||
var listElem = $(this).parents(".list");
|
||||
console.debug("Cancelling the edit of list " + listElem.data("list-id"));
|
||||
revertListChanges(listElem);
|
||||
});
|
||||
|
||||
$('.list-edit-button').on('click', function() {
|
||||
var listElem = $(this).parents(".list");
|
||||
console.debug("Going to edit list " + listElem.data("list-id"));
|
||||
enableListEditMode(listElem);
|
||||
});
|
||||
|
||||
$('.list-edit-help').on('click', function() {
|
||||
var listElem = $(this).parents(".list");
|
||||
var listDataElem = $('.list-data', listElem);
|
||||
$(this).toggleClass('active')
|
||||
listDataElem.popover('toggle');
|
||||
});
|
||||
|
||||
$('.list-save-button').on('click', function() {
|
||||
var listElem = $(this).parents(".list");
|
||||
console.debug("Saving the changes for list " + listElem.data("list-id"));
|
||||
saveListChanges(listElem);
|
||||
});
|
||||
|
||||
$('.list-select').on('click', function() {
|
||||
listId = $(this).data('list-id');
|
||||
selectList(listId);
|
||||
});
|
||||
|
||||
initLists();
|
||||
$('textarea').popover({
|
||||
html: true,
|
||||
trigger: 'manual'
|
||||
});
|
||||
});
|
|
@ -21,14 +21,14 @@ footer {
|
|||
line-height: 24px;
|
||||
}
|
||||
|
||||
// Reduce the large padding for notes
|
||||
.note-html ul,
|
||||
.note-html ol {
|
||||
// Reduce the large padding for lists
|
||||
.list-html ul,
|
||||
.list-html ol {
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
.note-html ul.tasknote {
|
||||
note-style-type: none;
|
||||
.list-html ul.tasklist {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}{{ title }}{% endblock title %}</title>
|
||||
<title>{% block title %}Online Prikbord Familie van Tilburg{% endblock title %}</title>
|
||||
|
||||
<!-- Favicons -->
|
||||
<link rel="icon" type="image/png" href="/favicon-16x16.png" sizes="16x16">
|
||||
|
@ -14,7 +14,7 @@
|
|||
<link rel="icon" type="image/png" href="/favicon-32x32.png" sizes="32x32">
|
||||
|
||||
<!-- Include CSS (including Bootstrap) -->
|
||||
<link href="/css/pinboard.css" rel="stylesheet">
|
||||
<link href="/css/wishlists.css" rel="stylesheet">
|
||||
|
||||
<!-- Include FontAwesome CSS -->
|
||||
<link href="/css/fontawesome-all.css" rel="stylesheet">
|
||||
|
@ -34,7 +34,7 @@
|
|||
{% block body %}
|
||||
<nav class="navbar navbar-expand-md fixed-top navbar-dark bg-dark text-light" role="navigation">
|
||||
<div class="container">
|
||||
<a class="navbar-brand note-select" href="#">{{ title }}</a>
|
||||
<a class="navbar-brand list-select" href="#">Online Prikbord</a>
|
||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
|
|
@ -3,13 +3,13 @@
|
|||
{% block navbar %}
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav mr-auto">
|
||||
{% for note in notes %}
|
||||
{% for list in lists %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link note-select" data-note-id="{{ note.id }}" href="#{{ note.id }}">{{ note.name }}</a>
|
||||
<a class="nav-link list-select" data-list-id="{{ list.id }}" href="#{{ list.id }}">{{ list.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<button class="btn btn-sm btn-light notes-refresh" title="Verversen">
|
||||
<button class="btn btn-sm btn-light lists-refresh" title="Verversen">
|
||||
<i class="fas fa-sync-alt"></i> Verversen
|
||||
</button>
|
||||
</div>
|
||||
|
@ -18,27 +18,27 @@
|
|||
|
||||
{% block content %}
|
||||
<div class="card-columns">
|
||||
{% for note in notes %}
|
||||
<div class="card note mb-3" style="display:none"; data-note-id="{{ note.id }}">
|
||||
{% for list in lists %}
|
||||
<div class="card list mb-3" style="display:none"; data-list-id="{{ list.id }}">
|
||||
<div class="card-header">
|
||||
<h1 class= "mb-0">
|
||||
<span id="note-name">{{ note.name }}</span>
|
||||
<div class="btn-toolbar float-right mt-1" role="toolbar" aria-label="Note buttons">
|
||||
<div class="btn-group note-edit-mode-buttons"
|
||||
<span id="list-name">{{ list.name }}</span>
|
||||
<div class="btn-toolbar float-right mt-1" role="toolbar" aria-label="List buttons">
|
||||
<div class="btn-group list-edit-mode-buttons"
|
||||
role="group" aria-label="Edit mode buttons">
|
||||
<button class="btn btn-dark note-edit-button" title="Bewerken">
|
||||
<button class="btn btn-dark list-edit-button" title="Bewerken">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="btn-group note-edit-buttons" role="group"
|
||||
aria-label="Edit note buttons">
|
||||
<button class="btn btn-light note-edit-help" title="Opmaakhulp">
|
||||
<div class="btn-group list-edit-buttons" role="group"
|
||||
aria-label="Edit list buttons">
|
||||
<button class="btn btn-light list-edit-help" title="Opmaakhulp">
|
||||
<i class="fas fa-question-circle"></i>
|
||||
</button>
|
||||
<button class="btn btn-light note-cancel-button" title="Annuleren">
|
||||
<button class="btn btn-light list-cancel-button" title="Annuleren">
|
||||
<i class="fas fa-ban"></i>
|
||||
</button>
|
||||
<button class="btn btn-dark note-save-button" title="Opslaan">
|
||||
<button class="btn btn-dark list-save-button" title="Opslaan">
|
||||
<i class="fas fa-save"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -46,9 +46,9 @@
|
|||
</h1>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-text note-html"></div>
|
||||
<div class="card-text list-html"></div>
|
||||
<div class="card-text">
|
||||
<textarea class="form-control note-data" rows="16"
|
||||
<textarea class="form-control list-data" rows="16"
|
||||
data-title="Opmaakhulp"
|
||||
data-content="<pre class='mb-0'>
|
||||
## Kop 2
|
||||
|
@ -76,7 +76,7 @@ Horizontale lijn<pre>"></textarea>
|
|||
<p class="card-text">
|
||||
<small>
|
||||
<i class="fas fa-clock"></i>
|
||||
<i class="note-updated-at"></i>
|
||||
<i class="list-updated-at"></i>
|
||||
</small>
|
||||
</p>
|
||||
</div>
|
||||
|
@ -106,11 +106,11 @@ Horizontale lijn<pre>"></textarea>
|
|||
{% block footer %}
|
||||
<footer class="footer bg-dark text-light">
|
||||
<div class="container text-center">
|
||||
<small>Rocket Pinboard v{{app_version}} — © 2018 Paul van Tilburg</small>
|
||||
<small>Wishlists v{{app_version}} — © 2018 Paul van Tilburg</small>
|
||||
</div>
|
||||
</footer>
|
||||
{% endblock footer %}
|
||||
|
||||
{% block script %}
|
||||
<script src="/js/pinboard.js"></script>
|
||||
<script src="/js/wishlists.js"></script>
|
||||
{% endblock script %}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
This is a test note
|
||||
This is a test list
|
||||
|
||||
* One
|
||||
* Two
|
Loading…
Reference in New Issue