├── .gitignore ├── .postcssrc.js ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── babel.config.js ├── netlify.toml ├── package-lock.json ├── package.json ├── public ├── config.yml └── index.html ├── src ├── SaleinaCMS.vue ├── assets │ ├── icons │ │ ├── add.svg │ │ ├── collection.svg │ │ ├── delete.svg │ │ ├── media.svg │ │ └── remove.svg │ ├── logo.green.svg │ └── logo.svg ├── components │ ├── collection │ │ ├── add.vue │ │ ├── collection.vue │ │ ├── edit-file-collection.vue │ │ ├── edit-folder-collection.vue │ │ ├── edit.vue │ │ ├── file-collection.vue │ │ └── folder-collection.vue │ ├── inc │ │ ├── button.vue │ │ ├── index.js │ │ ├── input.vue │ │ ├── loading.vue │ │ ├── media-preview.vue │ │ └── tabs │ │ │ ├── components │ │ │ ├── Tab.vue │ │ │ └── Tabs.vue │ │ │ ├── expiringStorage.js │ │ │ └── index.js │ ├── login.vue │ ├── main.vue │ ├── media.vue │ └── widgets │ │ ├── boolean.vue │ │ ├── date.vue │ │ ├── datetime.vue │ │ ├── file.vue │ │ ├── hidden.vue │ │ ├── image.vue │ │ ├── index.js │ │ ├── list.vue │ │ ├── markdown.vue │ │ ├── number.vue │ │ ├── object.vue │ │ ├── select.vue │ │ ├── string.vue │ │ └── text.vue ├── config.js ├── main.js ├── router.js ├── store │ ├── actions.js │ ├── backends │ │ └── gitlab │ │ │ ├── actions.js │ │ │ ├── index.js │ │ │ ├── media.actions.js │ │ │ ├── media.mutations.js │ │ │ └── mutations.js │ ├── index.js │ └── mutations.js ├── styles.css └── utils │ ├── lib.js │ └── schema.js ├── vue.config.js └── website ├── README.md ├── config.toml ├── content ├── _index.md ├── blog │ └── webhooks.md ├── docs │ ├── add-to-your-site.md │ ├── backends.md │ ├── boolean.md │ ├── collection-types.md │ ├── configuration-options.md │ ├── date.md │ ├── datetime.md │ ├── file.md │ ├── hidden.md │ ├── image.md │ ├── introduction.md │ ├── list.md │ ├── markdown.md │ ├── number.md │ ├── object.md │ ├── select.md │ ├── string.md │ ├── text.md │ ├── update-the-cms-version.md │ └── widgets.md └── support │ └── _index.md ├── layouts ├── _default │ ├── baseof.html │ ├── li.html │ ├── list.html │ └── single.html ├── docs │ └── single.html ├── index.html └── section │ └── support.html └── static ├── _redirects ├── admin ├── config.yml └── index.html ├── css └── style.styl └── images └── logo.png /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | website/static/css/*.css 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | 15 | # Editor directories and files 16 | .idea 17 | .vscode 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw* 23 | -------------------------------------------------------------------------------- /.postcssrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | } -------------------------------------------------------------------------------- /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 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at admin@zede.solutions. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # CONTRIBUTING 2 | 3 | Contributions are always welcome, no matter how large or small. Before contributing, 4 | please read the [code of conduct](CODE_OF_CONDUCT.md). 5 | 6 | ## Setup 7 | 8 | > Install node on your system: [Node](https://nodejs.org/en/download) 9 | 10 | ### Install dependencies 11 | 12 | > Only required on the first run, subsequent runs can use `npm run serve` to run the development server. 13 | 14 | ```sh 15 | $ git clone https://github.com/saleina/SaleinaCMS 16 | $ cd SaleinaCMS 17 | $ npm install 18 | ``` 19 | 20 | ### Run locally 21 | 22 | ```sh 23 | $ npm run serve 24 | ``` 25 | 26 | ## Pull Requests 27 | 28 | We actively welcome your pull requests! 29 | 30 | If you need help with Git or our workflow, please ask on [Telegram](tg://resolve?domain=saleinacmsdiscussions). We want your contributions even if you're just learning Git. Our maintainers are happy to help! 31 | 32 | Saleina CMS uses the [Forking Workflow](https://www.atlassian.com/git/tutorials/comparing-workflows#forking-workflow) + [Feature Branches](https://www.atlassian.com/git/tutorials/comparing-workflows#feature-branch-workflow). Additionally, PR's should be [rebased](https://www.atlassian.com/git/tutorials/merging-vs-rebasing) on master when opened, and again before merging. 33 | 34 | 1. Fork the repo. 35 | 2. Create a branch from `master`. If you're addressing a specific issue, prefix your branch name with the issue number. 36 | 3. If you've added code that should be tested, add tests. 37 | 4. If you've changed APIs, update the documentation. 38 | 5. PR's must be rebased before merge (feel free to ask for help). 39 | 6. PR should be reviewed by two maintainers prior to merging. 40 | 41 | ## License 42 | 43 | By contributing to Saleina CMS, you agree that your contributions will be licensed 44 | under its [MIT license](LICENSE). 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Zelalem Mekonen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | cd website ; hugo --minify ; npm install ; ../node_modules/.bin/stylus --include ../node_modules/nib/nib ./public/css -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Saleina CMS 2 | 3 | [![](https://data.jsdelivr.com/v1/package/npm/saleina-cms/badge)](https://www.jsdelivr.com/package/npm/saleina-cms) 4 | 5 | A CMS for static site generators. Give non-technical users a simple way to edit 6 | and add content to any site built with a static site generator. 7 | 8 | ## How it works 9 | 10 | Saleina CMS is a single-page app that you pull into the `/admin` part of your site. 11 | 12 | It presents a clean UI for editing content stored in a Git repository. 13 | 14 | You setup a YAML config to describe the content model of your site, and typically 15 | tweak the main layout of the CMS a bit to fit your own site. 16 | 17 | When a user navigates to `/admin` they'll be prompted to login, and once authenticated 18 | they'll be able to create new content or edit existing content. 19 | 20 | Read more about Saleina CMS [Introduction](https://saleinacms.org/docs/introduction/). 21 | 22 | # Installation and Configuration 23 | 24 | A Quick and easy install, that just requires you to create a single HTML file and a configuration file. All the CMS files are loaded from a CDN. To learn more about this installation method, refer to the [Docs](https://saleinacms.org/docs/introduction/) 25 | 26 | # Support Us 27 | 28 | 29 | 30 | ## Our Sponsors 31 | 32 | 33 | # Change Log 34 | 35 | This project adheres to [Semantic Versioning](http://semver.org/). 36 | Every release is documented on the Github [Releases](https://github.com/saleina/SaleinaCMS/releases) page. 37 | 38 | # License 39 | 40 | Saleina CMS is released under the [MIT License](LICENSE). 41 | Please make sure you understand its [implications and guarantees](https://writing.kemitchell.com/2016/09/21/MIT-License-Line-by-Line.html). -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/app' 4 | ] 5 | } -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "make build" 3 | "publish" = "website/public" -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "saleina-cms", 3 | "version": "0.32.1", 4 | "author": { 5 | "name": "Zelalem Mekonen", 6 | "email": "zola@programmer.net" 7 | }, 8 | "description": "Static site CMS with git as a backend", 9 | "license": "MIT", 10 | "scripts": { 11 | "serve": "vue-cli-service serve", 12 | "build": "vue-cli-service build" 13 | }, 14 | "files": [ 15 | "dist/saleina-cms.min.js" 16 | ], 17 | "dependencies": { 18 | "flatpickr": "^4.5.1", 19 | "gray-matter": "^4.0.1", 20 | "mustache": "^2.3.2", 21 | "pell": "^1.0.4", 22 | "showdown": "^1.8.6", 23 | "slugify": "^1.3.1", 24 | "toml-js": "0.0.8", 25 | "turndown": "^4.0.2", 26 | "validate.js": "^0.12.0", 27 | "vue": "^2.5.17", 28 | "vue-router": "^3.0.1", 29 | "vue-toasted": "^1.1.24", 30 | "vuex": "^3.0.1", 31 | "vuex-router-sync": "^5.0.0" 32 | }, 33 | "devDependencies": { 34 | "@vue/cli-plugin-babel": "^3.0.0", 35 | "@vue/cli-service": "^3.0.0", 36 | "nib": "^1.1.2", 37 | "stylus": "^0.54.5", 38 | "vue-svg-loader": "^0.5.0", 39 | "vue-template-compiler": "^2.5.17" 40 | }, 41 | "browserslist": [ 42 | "> 1%", 43 | "last 2 versions", 44 | "not ie <= 8" 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /public/config.yml: -------------------------------------------------------------------------------- 1 | backend: 2 | name: gitlab 3 | repo: saleina/saleinacms 4 | client_id: 83cb793b4a6a96883c9502178b2c58151310851f9e44263f9ff19c3ad0da7357 5 | 6 | media_folder: /website/static/images/uploads/ 7 | 8 | public_folder: /images/uploads/ 9 | 10 | collections: 11 | - label: Blog 12 | name: blog 13 | folder: website/content/blog 14 | delete: true 15 | tabs: 16 | - label: Basic 17 | fields: 18 | - {label: Draft, name: draft, widget: boolean, default: true} 19 | - {label: Title, name: title, widget: string} 20 | - {label: Publish Date, name: date, widget: datetime, format: "Z"} 21 | - label: Body 22 | fields: 23 | - {label: Body, name: body, widget: markdown} 24 | 25 | - label: Docs 26 | name: docs 27 | create: false 28 | folder: website/content/docs 29 | tabs: 30 | - label: Basic 31 | fields: 32 | - {label: Title, name: title, widget: string} 33 | - label: Group 34 | name: group 35 | widget: select 36 | options: 37 | - {label: Start, value: start} 38 | - {label: Widgets, value: widgets} 39 | - {label: Reference, value: reference} 40 | - {label: Weight, name: weight, widget: number, required: false} 41 | - label: Body 42 | fields: 43 | - {label: Body, name: body, widget: markdown} 44 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | SaleinaCMS Development Test 8 | 9 | 10 | 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /src/SaleinaCMS.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | 15 | -------------------------------------------------------------------------------- /src/assets/icons/add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/assets/icons/collection.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/assets/icons/delete.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/assets/icons/media.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/assets/icons/remove.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/components/collection/add.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | -------------------------------------------------------------------------------- /src/components/collection/collection.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 41 | 42 | -------------------------------------------------------------------------------- /src/components/collection/edit-file-collection.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 241 | 242 | -------------------------------------------------------------------------------- /src/components/collection/edit-folder-collection.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 242 | 243 | -------------------------------------------------------------------------------- /src/components/collection/edit.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 38 | 39 | -------------------------------------------------------------------------------- /src/components/collection/file-collection.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | -------------------------------------------------------------------------------- /src/components/collection/folder-collection.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 132 | 133 | -------------------------------------------------------------------------------- /src/components/inc/button.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 35 | 36 | -------------------------------------------------------------------------------- /src/components/inc/index.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | 3 | const requireComponent = require.context(".", false, /.\.vue$/); 4 | 5 | requireComponent.keys().forEach(fileName => { 6 | 7 | const componentConfig = requireComponent(fileName); 8 | 9 | Vue.component(componentConfig.default.name, componentConfig.default); 10 | 11 | }); -------------------------------------------------------------------------------- /src/components/inc/input.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 44 | 45 | -------------------------------------------------------------------------------- /src/components/inc/loading.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 29 | 30 | -------------------------------------------------------------------------------- /src/components/inc/media-preview.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 149 | 150 | 205 | -------------------------------------------------------------------------------- /src/components/inc/tabs/components/Tab.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 44 | -------------------------------------------------------------------------------- /src/components/inc/tabs/components/Tabs.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 129 | -------------------------------------------------------------------------------- /src/components/inc/tabs/expiringStorage.js: -------------------------------------------------------------------------------- 1 | class ExpiringStorage { 2 | get(key) { 3 | const cached = JSON.parse( 4 | localStorage.getItem(key) 5 | ); 6 | 7 | if (! cached) { 8 | return null; 9 | } 10 | 11 | const expires = new Date(cached.expires); 12 | 13 | if (expires < new Date()) { 14 | localStorage.removeItem(key); 15 | return null; 16 | } 17 | 18 | return cached.value; 19 | } 20 | 21 | set(key, value, lifeTimeInMinutes) { 22 | const currentTime = new Date().getTime(); 23 | 24 | const expires = new Date(currentTime + lifeTimeInMinutes * 60000); 25 | 26 | localStorage.setItem(key, JSON.stringify({ value, expires })); 27 | } 28 | } 29 | 30 | export default new ExpiringStorage(); 31 | -------------------------------------------------------------------------------- /src/components/inc/tabs/index.js: -------------------------------------------------------------------------------- 1 | import Tab from './components/Tab'; 2 | import Tabs from './components/Tabs'; 3 | 4 | export default { 5 | install(Vue) { 6 | Vue.component('tab', Tab); 7 | Vue.component('tabs', Tabs); 8 | }, 9 | }; 10 | 11 | export { Tab, Tabs }; 12 | -------------------------------------------------------------------------------- /src/components/login.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 25 | 26 | -------------------------------------------------------------------------------- /src/components/main.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 94 | 95 | -------------------------------------------------------------------------------- /src/components/media.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 150 | 151 | 195 | -------------------------------------------------------------------------------- /src/components/widgets/boolean.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 45 | 46 | -------------------------------------------------------------------------------- /src/components/widgets/date.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/widgets/datetime.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/widgets/file.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 80 | 81 | -------------------------------------------------------------------------------- /src/components/widgets/hidden.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/widgets/image.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 80 | 81 | -------------------------------------------------------------------------------- /src/components/widgets/index.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | 3 | const requireComponent = require.context(".", false, /.\.vue$/); 4 | 5 | requireComponent.keys().forEach(fileName => { 6 | 7 | const componentConfig = requireComponent(fileName); 8 | 9 | Vue.component(componentConfig.default.name, componentConfig.default); 10 | 11 | }); -------------------------------------------------------------------------------- /src/components/widgets/list.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 177 | 178 | -------------------------------------------------------------------------------- /src/components/widgets/markdown.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/widgets/number.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | -------------------------------------------------------------------------------- /src/components/widgets/object.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 77 | 78 | -------------------------------------------------------------------------------- /src/components/widgets/select.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | -------------------------------------------------------------------------------- /src/components/widgets/string.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | -------------------------------------------------------------------------------- /src/components/widgets/text.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | const options = { 2 | type: "array", 3 | minItems: 1, 4 | items: { 5 | oneOf: [ 6 | { 7 | type: "string" 8 | }, 9 | { 10 | type: "object", 11 | required: ["label", "value"], 12 | properties: { 13 | label: { 14 | type: "string" 15 | }, 16 | value: { 17 | type: "string" 18 | } 19 | } 20 | } 21 | ] 22 | } 23 | }; 24 | 25 | const boolean = { 26 | type: "object", 27 | required: ["label", "name", "widget"], 28 | additionalProperties: false, 29 | properties: { 30 | label: { 31 | type: "string" 32 | }, 33 | name: { 34 | type: "string" 35 | }, 36 | widget: { 37 | type: "string", 38 | const: "boolean" 39 | }, 40 | default: { 41 | type: "boolean" 42 | }, 43 | required: { 44 | type: "boolean" 45 | } 46 | } 47 | }; 48 | 49 | const select = { 50 | type: "object", 51 | required: ["label", "name", "widget", "options"], 52 | additionalProperties: false, 53 | properties: { 54 | label: { 55 | type: "string" 56 | }, 57 | name: { 58 | type: "string" 59 | }, 60 | widget: { 61 | type: "string", 62 | const: "s-select" 63 | }, 64 | options: options, 65 | default: { 66 | type: "string" 67 | } 68 | } 69 | }; 70 | 71 | const number = { 72 | type: "object", 73 | required: ["label", "name", "widget"], 74 | additionalProperties: false, 75 | properties: { 76 | label: { 77 | type: "string" 78 | }, 79 | name: { 80 | type: "string" 81 | }, 82 | widget: { 83 | type: "string", 84 | const: "number" 85 | }, 86 | default: { 87 | type: "number" 88 | }, 89 | max: { 90 | type: "number" 91 | }, 92 | min: { 93 | type: "number" 94 | }, 95 | required: { 96 | type: "boolean" 97 | } 98 | } 99 | }; 100 | 101 | const image = { 102 | type: "object", 103 | required: ["label", "name", "widget"], 104 | additionalProperties: false, 105 | properties: { 106 | label: { 107 | type: "string" 108 | }, 109 | name: { 110 | type: "string" 111 | }, 112 | widget: { 113 | type: "string", 114 | const: "s-image" 115 | }, 116 | default: { 117 | type: "string" 118 | }, 119 | required: { 120 | type: "boolean" 121 | } 122 | } 123 | }; 124 | 125 | const object = { 126 | type: "object", 127 | required: ["label", "name", "widget", "fields"], 128 | additionalProperties: false, 129 | properties: { 130 | label: { 131 | type: "string" 132 | }, 133 | name: { 134 | type: "string" 135 | }, 136 | widget: { 137 | type: "string", 138 | const: "s-object" 139 | }, 140 | fields: { 141 | "$ref": "fields.json" 142 | }, 143 | required: { 144 | type: "boolean" 145 | } 146 | } 147 | }; 148 | 149 | const hidden = { 150 | type: "object", 151 | required: ["label", "name", "widget", "default"], 152 | additionalProperties: false, 153 | properties: { 154 | label: { 155 | type: "string" 156 | }, 157 | name: { 158 | type: "string" 159 | }, 160 | widget: { 161 | type: "string", 162 | const: "hidden" 163 | }, 164 | default: {} 165 | } 166 | }; 167 | 168 | const list = { 169 | type: "object", 170 | required: ["label", "name", "widget"], 171 | additionalProperties: false, 172 | properties: { 173 | label: { 174 | type: "string" 175 | }, 176 | name: { 177 | type: "string" 178 | }, 179 | widget: { 180 | type: "string", 181 | const: "list" 182 | }, 183 | fields: { 184 | "$ref": "fields.json" 185 | }, 186 | min: { 187 | type: "number" 188 | }, 189 | max: { 190 | type: "number" 191 | }, 192 | required: { 193 | type: "boolean" 194 | } 195 | } 196 | }; 197 | 198 | const date = { 199 | type: "object", 200 | additionalProperties: false, 201 | required: ["label", "name", "widget"], 202 | properties: { 203 | label: { 204 | type: "string" 205 | }, 206 | name: { 207 | type: "string" 208 | }, 209 | widget: { 210 | type: "string", 211 | const: "date" 212 | }, 213 | default: { 214 | type: "string" 215 | }, 216 | required: { 217 | type: "boolean" 218 | }, 219 | format: { 220 | type: "string" 221 | }, 222 | min: { 223 | type: "string" 224 | }, 225 | max: { 226 | type: "string" 227 | } 228 | } 229 | }; 230 | 231 | const datetime = { 232 | type: "object", 233 | additionalProperties: false, 234 | required: ["label", "name", "widget"], 235 | properties: { 236 | label: { 237 | type: "string" 238 | }, 239 | name: { 240 | type: "string" 241 | }, 242 | widget: { 243 | type: "string", 244 | const: "datetime" 245 | }, 246 | default: { 247 | type: "string" 248 | }, 249 | required: { 250 | type: "boolean" 251 | }, 252 | format: { 253 | type: "string" 254 | }, 255 | min: { 256 | type: "string" 257 | }, 258 | max: { 259 | type: "string" 260 | } 261 | } 262 | }; 263 | 264 | const others = { 265 | type: "object", 266 | additionalProperties: false, 267 | required: ["label", "name", "widget"], 268 | properties: { 269 | label: { 270 | type: "string" 271 | }, 272 | name: { 273 | type: "string" 274 | }, 275 | widget: { 276 | type: "string", 277 | enum: [ 278 | "string", 279 | "file", 280 | "markdown", 281 | "text", 282 | "s-text" 283 | ] 284 | }, 285 | default: { 286 | type: "string" 287 | }, 288 | required: { 289 | type: "boolean" 290 | }, 291 | pattern: { 292 | type: "string", 293 | format: "regex" 294 | }, 295 | max: { 296 | type: "number", 297 | minimum: 1 298 | }, 299 | min: { 300 | type: "number", 301 | minimum: 1 302 | } 303 | } 304 | }; 305 | 306 | const fields = { 307 | $id: "https://saleinacms.com/schemas/fields.json", 308 | type: "array", 309 | minItems: 1, 310 | items: { 311 | oneOf: [ 312 | boolean, 313 | select, 314 | number, 315 | object, 316 | list, 317 | others, 318 | image, 319 | hidden, 320 | date, 321 | datetime 322 | ] 323 | } 324 | }; 325 | 326 | const tabs = { 327 | type: "array", 328 | minItems: 1, 329 | items: { 330 | type: "object", 331 | required: ["label", "fields"], 332 | properties: { 333 | label: { 334 | type: "string" 335 | }, 336 | fields: fields 337 | } 338 | } 339 | }; 340 | 341 | const hooks = { 342 | type: "object", 343 | properties: { 344 | created: { 345 | type: "string" 346 | }, 347 | updated: { 348 | type: "string" 349 | }, 350 | deleted: { 351 | type: "string" 352 | } 353 | } 354 | } 355 | 356 | const folderCollections = { 357 | $id: "https://saleinacms.com/schemas/folder-collections.json", 358 | type: "object", 359 | required: ["label", "name", "folder", "tabs"], 360 | properties: { 361 | label: { 362 | type: "string" 363 | }, 364 | name: { 365 | type: "string" 366 | }, 367 | folder: { 368 | type: "string" 369 | }, 370 | delete: { 371 | type: "boolean" 372 | }, 373 | create: { 374 | type: "boolean" 375 | }, 376 | description: { 377 | type: "string" 378 | }, 379 | hooks: hooks, 380 | type: { 381 | type: "string", 382 | enum: [ 383 | "yml", 384 | "toml", 385 | "json", 386 | "md" 387 | ], 388 | default: "md" 389 | }, 390 | slug: { 391 | type: "string" 392 | }, 393 | tabs: tabs 394 | } 395 | }; 396 | 397 | const fileCollections = { 398 | type: "object", 399 | required: ["label", "name", "files"], 400 | properties: { 401 | label: { 402 | type: "string" 403 | }, 404 | delete: { 405 | type: "boolean" 406 | }, 407 | name: { 408 | type: "string" 409 | }, 410 | description: { 411 | type: "string" 412 | }, 413 | hooks: hooks, 414 | files: { 415 | type: "array", 416 | minItems: 1, 417 | items: { 418 | type: "object", 419 | required: ["label", "name", "file", "tabs"], 420 | properties: { 421 | label: { 422 | type: "string" 423 | }, 424 | name: { 425 | type: "string" 426 | }, 427 | delete: { 428 | type: "boolean", 429 | default: false 430 | }, 431 | file: { 432 | type: "string", 433 | pattern: ".+\.json|yml|toml|md" 434 | }, 435 | tabs: tabs 436 | } 437 | } 438 | } 439 | } 440 | }; 441 | 442 | const collections = { 443 | type: "array", 444 | minItems: 1, 445 | items: { 446 | oneOf: [ 447 | fileCollections, 448 | folderCollections 449 | ] 450 | } 451 | }; 452 | 453 | const gitlab = { 454 | required: ["name", "repo", "client_id"], 455 | properties: { 456 | name: { 457 | type: "string", 458 | enum: ["gitlab"] 459 | }, 460 | repo: { 461 | type: "string", 462 | pattern: "[\\w\\d]+\/[\\w\\d]+" 463 | }, 464 | client_id: { 465 | type: "string" 466 | }, 467 | branch: { 468 | type: "string", 469 | default: "master" 470 | } 471 | } 472 | }; 473 | 474 | export default { 475 | type: "object", 476 | required: ["backend", "collections", "media_folder"], 477 | additionalProperties: false, 478 | properties: { 479 | backend: { 480 | type: "object", 481 | oneOf: [ 482 | gitlab 483 | ] 484 | }, 485 | 486 | url: { 487 | type: "string", 488 | format: "uri" 489 | }, 490 | 491 | media_folder: { 492 | type: "string" 493 | }, 494 | 495 | public_folder: { 496 | type: "string" 497 | }, 498 | 499 | logo: { 500 | type: "string" 501 | }, 502 | 503 | collections: collections 504 | } 505 | }; -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import { sync } from "vuex-router-sync"; 3 | import yaml from "js-yaml"; 4 | import { Tabs, Tab } from "./components/inc/tabs"; 5 | import Toasted from "vue-toasted"; 6 | import validate from "validate.js"; 7 | import { 8 | correctWidgetNames, 9 | objectifyCollections, 10 | collectionsHaveTitleAndBody 11 | } from "./utils/lib"; 12 | 13 | import "./styles.css"; // global styles 14 | 15 | import router from "./router"; 16 | import store from "./store"; 17 | import gitlab from "./store/backends/gitlab"; 18 | 19 | import SaleinaCMS from "./SaleinaCMS"; 20 | 21 | import Globals from "./components/inc"; 22 | import Widgets from "./components/widgets"; 23 | 24 | import schema from "./config.js"; 25 | import AJV from "ajv"; 26 | 27 | const ajv = new AJV({ 28 | allErrors: true, 29 | useDefaults: true 30 | }); 31 | 32 | const valid = ajv.compile(schema); 33 | 34 | Vue.use(Toasted, { 35 | position: "bottom-right", 36 | duration: 5000 37 | }); 38 | 39 | Vue.component("tabs", Tabs); 40 | Vue.component("tab", Tab); 41 | 42 | Vue.config.productionTip = false; 43 | 44 | sync(store, router); 45 | 46 | validate.formatters.custom = function(errors) { 47 | let data = {}; 48 | for (let error in errors) { 49 | if (!errors.hasOwnProperty(error)) continue; 50 | data[errors[error].attribute] = errors[error].error; 51 | } 52 | return data; 53 | }; 54 | 55 | Vue.prototype.$validate = function(object, constraints) { 56 | return validate(object, constraints, {format: "custom", fullMessages: false}); 57 | }; 58 | 59 | Vue.prototype.$fetch = function(url, method, data) { 60 | return new Promise((resolve, reject) => { 61 | fetch(url, { 62 | method: method || "GET", 63 | body: JSON.stringify(data), 64 | mode: "cors", 65 | redirect: "follow", 66 | headers: { 67 | "Content-Type": "application/json; charset=utf-8", 68 | "Authorization": `Bearer ${store.state.token}` 69 | } 70 | }) 71 | .then(response => resolve(response)) 72 | .catch(error => reject(error)); 73 | }); 74 | }; 75 | 76 | let request = window.indexedDB.open("saleina-cms", 1); 77 | 78 | request.onupgradeneeded = function(event) { 79 | 80 | let db = event.target.result; 81 | 82 | if (!db.objectStoreNames.contains("media")) { 83 | 84 | db.createObjectStore("media", {autoIncrement: false}); 85 | 86 | } 87 | 88 | }; 89 | 90 | request.onsuccess = function() { 91 | Vue.prototype.$db = request.result; 92 | }; 93 | 94 | request.onerror = function(error) { 95 | console.error(error); 96 | }; 97 | 98 | function supported() { 99 | 100 | return Object.defineProperty && window.indexedDB && document.body.dataset && window.Promise && window.history; 101 | 102 | }; 103 | 104 | new Vue({ 105 | router, 106 | store, 107 | render: h => h(SaleinaCMS), 108 | 109 | async created() { 110 | 111 | // check if browser is supported 112 | if (!supported()) { 113 | 114 | store.commit("updateConfigError", true); 115 | 116 | store.commit("updateConfigErrorMessage", "Oops! Your browser doesn't seem to be supported"); 117 | 118 | store.commit("updateLoading", false); 119 | 120 | return; 121 | 122 | } 123 | 124 | try { 125 | 126 | let response = await fetch("/admin/config.yml"); 127 | 128 | let data = await response.text(); 129 | 130 | data = correctWidgetNames(data); 131 | 132 | let config = await yaml.load(data); 133 | 134 | let configInvalid = !valid(config); 135 | 136 | if (configInvalid) throw valid.errors; 137 | 138 | if (!collectionsHaveTitleAndBody(config.collections)) throw { 139 | name: "NoTitleOrBody" 140 | }; 141 | 142 | store.commit("loadConfig", config); 143 | 144 | store.commit("setCollections", objectifyCollections(config.collections)); 145 | 146 | let backend = store.state.config.config.backend.name === "gitlab" ? gitlab : null; 147 | 148 | store.registerModule("backend", backend); 149 | 150 | const tokenRegExp = /access_token=([\w\d]+)/; 151 | 152 | let found = this.$route.path.match(tokenRegExp); 153 | 154 | if (found) { 155 | 156 | let token = found[1]; 157 | 158 | localStorage.setItem("token", token); 159 | 160 | store.commit("login", token); 161 | 162 | } else { 163 | 164 | let token = localStorage.getItem("token"); 165 | 166 | if (!token) { 167 | 168 | store.commit("updateLoading", false); 169 | 170 | return router.replace("/login/"); 171 | 172 | } 173 | 174 | store.commit("login", token); 175 | 176 | } 177 | 178 | let user = await store.dispatch("getUser"); 179 | 180 | let isMember = await store.dispatch("hasAccess", user); 181 | 182 | if (!isMember) { 183 | store.commit("logout"); 184 | throw { 185 | name: "PermissionError" 186 | } 187 | } 188 | 189 | store.commit("updateUser", user); 190 | 191 | store.commit("updateLoading", false); 192 | 193 | router.replace("/"); 194 | 195 | } catch(error) { 196 | 197 | if (error.name === "PermissionError") { 198 | 199 | store.commit("updateConfigErrorMessage", "It seems you don't have access to this site!") 200 | 201 | } else { 202 | 203 | store.commit("updateConfigErrorMessage", "Configuration Errors! Please check your console for details"); 204 | 205 | } 206 | 207 | store.commit("logout"); 208 | 209 | store.commit("updateConfigError", true); 210 | 211 | store.commit("updateLoading", false); 212 | 213 | console.error(error); 214 | 215 | } 216 | } 217 | }).$mount("#SaleinaCMS"); 218 | -------------------------------------------------------------------------------- /src/router.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import Router from "vue-router"; 3 | import Login from "./components/login"; 4 | import Main from "./components/main"; 5 | import Collection from "./components/collection/collection"; 6 | import AddCollection from "./components/collection/add"; 7 | import EditCollection from "./components/collection/edit"; 8 | import store from "./store"; 9 | 10 | Vue.use(Router); 11 | 12 | export default new Router({ 13 | base: "/admin/", 14 | routes: [ 15 | { 16 | path: "/login/", 17 | component: Login 18 | }, 19 | { 20 | path: "/", 21 | component: Main, 22 | beforeEnter(to, from, next) { 23 | if (store.getters.loggedIn) return next(); 24 | next("/login/"); 25 | }, 26 | children: [ 27 | { 28 | path: "/collections/:name/", 29 | component: Collection 30 | }, 31 | { 32 | path: "/collections/:name/add/", 33 | component: AddCollection 34 | }, 35 | { 36 | path: "/collections/:name/:file/", 37 | component: EditCollection 38 | } 39 | ] 40 | } 41 | ] 42 | }); 43 | -------------------------------------------------------------------------------- /src/store/actions.js: -------------------------------------------------------------------------------- 1 | export default {}; 2 | -------------------------------------------------------------------------------- /src/store/backends/gitlab/actions.js: -------------------------------------------------------------------------------- 1 | import MediaActions from "./media.actions"; 2 | 3 | export default { 4 | 5 | auth(context) { 6 | 7 | let redirectURI = window.location.origin + "/admin/"; 8 | 9 | let clientID = context.rootState.config.config.backend.client_id; 10 | 11 | window.location.replace(`https://gitlab.com/oauth/authorize?client_id=${clientID}&response_type=token&redirect_uri=${redirectURI}`); 12 | 13 | }, 14 | 15 | getUser() { 16 | 17 | return new Promise((resolve, reject) => { 18 | 19 | this._vm.$fetch(`https://gitlab.com/api/v4/user`) 20 | .then(response => { 21 | if (response.ok) return response.json(); 22 | reject(response.status); 23 | }) 24 | .then(user => { 25 | resolve(user); 26 | }) 27 | .catch(error => { 28 | reject(error); 29 | }); 30 | 31 | }); 32 | 33 | }, 34 | 35 | hasAccess(context, user) { 36 | 37 | return new Promise((resolve, reject) => { 38 | 39 | this._vm.$fetch(`https://gitlab.com/api/v4/projects/${context.getters.id}/members/all`) 40 | .then(response => { 41 | if (response.ok) return response.json(); 42 | reject(false); 43 | }) 44 | .then(members => { 45 | let isMember = members.some(member => member.username === user.username && member.access_level >= 40); 46 | resolve(isMember); 47 | }) 48 | .catch(error => { 49 | reject(error); 50 | }); 51 | 52 | }); 53 | 54 | }, 55 | 56 | getFilesForCollection(context, file) { 57 | 58 | return new Promise((resolve, reject) => { 59 | 60 | let id = context.getters.id; 61 | 62 | let path = encodeURIComponent(file.path); 63 | 64 | let totalPages = 1; 65 | 66 | let branch = encodeURIComponent(context.getters.branch); 67 | 68 | this._vm.$fetch(`https://gitlab.com/api/v4/projects/${id}/repository/tree?path=${path}&ref=${branch}&page=${file.page}&per_page=${file.perPage}`) 69 | .then(response => { 70 | 71 | totalPages = response.headers.get("X-Total-Pages"); 72 | 73 | if (response.ok) return response.json(); 74 | 75 | reject(response.status); 76 | 77 | }) 78 | .then(files => { 79 | 80 | resolve({ 81 | files: files, 82 | totalPages: parseInt(totalPages) 83 | }); 84 | 85 | }) 86 | .catch(error => { 87 | 88 | reject(error); 89 | 90 | }); 91 | 92 | }); 93 | 94 | }, 95 | 96 | searchInCollection(context, data) { 97 | 98 | let id = context.getters.id; 99 | 100 | let path = data.path; 101 | 102 | data.keyword = encodeURIComponent(`${data.keyword} path:${path} extension:md`); 103 | 104 | let totalPages = 1; 105 | 106 | return new Promise((resolve, reject) => { 107 | 108 | this._vm.$fetch(`https://gitlab.com/api/v4/projects/${id}/search?scope=blobs&search=${data.keyword}&per_page=${data.perPage}&page=${data.page}`) 109 | .then(response => { 110 | 111 | totalPages = response.headers.get("X-Total-Pages"); 112 | 113 | if (response.ok) return response.json(); 114 | 115 | reject(response.status); 116 | 117 | }) 118 | .then(files => { 119 | 120 | let uniqueFileNames = new Set(); 121 | 122 | for (let index in files) { 123 | 124 | let parts = files[index].filename.split("/"); 125 | 126 | let filename = parts[parts.length - 1]; 127 | 128 | uniqueFileNames.add(filename); 129 | 130 | } 131 | 132 | let uniqueFiles = []; 133 | 134 | uniqueFileNames.forEach(fileName => { 135 | 136 | uniqueFiles.push({ 137 | name: fileName 138 | }); 139 | 140 | }); 141 | 142 | resolve({ 143 | files: uniqueFiles, 144 | totalPages: totalPages 145 | }); 146 | 147 | }) 148 | .catch(error => { 149 | reject(error); 150 | }) 151 | 152 | }); 153 | 154 | }, 155 | 156 | addFile(context, file) { 157 | 158 | let id = context.getters.id; 159 | 160 | let path = encodeURIComponent(file.path); 161 | 162 | let data = { 163 | branch: context.getters.branch, 164 | commit_message: `create file ${path}`, 165 | actions: [ 166 | { 167 | action: "create", 168 | file_path: file.path, 169 | encoding: file.encoding || "text", 170 | content: file.content 171 | } 172 | ] 173 | }; 174 | 175 | return new Promise((resolve, reject) => { 176 | 177 | this._vm.$fetch(`https://gitlab.com/api/v4/projects/${id}/repository/commits`, "POST", data) 178 | .then(response => { 179 | 180 | if (response.ok) return resolve(file.name); 181 | 182 | reject(response.status); 183 | 184 | }) 185 | .catch(error => { 186 | reject(error); 187 | }); 188 | 189 | }); 190 | 191 | }, 192 | 193 | updateFile(context, file) { 194 | 195 | let id = context.getters.id; 196 | 197 | let data = { 198 | branch: context.getters.branch, 199 | commit_message: `update file ${file.path}`, 200 | actions: [ 201 | { 202 | action: "update", 203 | file_path: file.path, 204 | content: file.content 205 | } 206 | ] 207 | }; 208 | 209 | return new Promise((resolve, reject) => { 210 | 211 | this._vm.$fetch(`https://gitlab.com/api/v4/projects/${id}/repository/commits`, "POST", data) 212 | .then(response => { 213 | if (response.ok) return resolve(); 214 | reject(response.status); 215 | }) 216 | .catch(error => { 217 | reject(error); 218 | }); 219 | 220 | }); 221 | 222 | }, 223 | 224 | deleteFile(context, file) { 225 | 226 | let id = context.getters.id; 227 | 228 | let data = { 229 | branch: context.getters.branch, 230 | commit_message: `delete file ${file.path}`, 231 | actions: [ 232 | { 233 | "action": "delete", 234 | "file_path": file.path 235 | } 236 | ] 237 | }; 238 | 239 | return new Promise((resolve, reject) => { 240 | 241 | this._vm.$fetch(`https://gitlab.com/api/v4/projects/${id}/repository/commits`, "POST", data) 242 | .then(response => { 243 | 244 | if (response.ok) return resolve(); 245 | reject(response.status); 246 | 247 | }) 248 | .catch(error => { 249 | 250 | reject(error); 251 | 252 | }); 253 | 254 | }); 255 | 256 | }, 257 | 258 | getFile(context, file) { 259 | 260 | return new Promise((resolve, reject) => { 261 | 262 | let id = context.getters.id; 263 | 264 | let path = encodeURIComponent(file.path); 265 | 266 | let branch = context.getters.branch; 267 | 268 | this._vm.$fetch(`https://gitlab.com/api/v4/projects/${id}/repository/files/${path}/raw?ref=${branch}`) 269 | .then(response => { 270 | 271 | if (response.ok) return response.text(); 272 | 273 | reject(response.status); 274 | 275 | }) 276 | .then(file => { 277 | 278 | resolve(file); 279 | 280 | }) 281 | .catch(error => { 282 | 283 | reject(error); 284 | 285 | }); 286 | 287 | }); 288 | 289 | }, 290 | 291 | ...MediaActions 292 | 293 | }; 294 | -------------------------------------------------------------------------------- /src/store/backends/gitlab/index.js: -------------------------------------------------------------------------------- 1 | import actions from "./actions"; 2 | import mutations from "./mutations"; 3 | 4 | export default { 5 | state: { 6 | media: { 7 | files: [], 8 | page: 1, 9 | perPage: 10, 10 | totalPages: 1 11 | } 12 | }, 13 | actions: actions, 14 | mutations: mutations 15 | }; 16 | -------------------------------------------------------------------------------- /src/store/backends/gitlab/media.actions.js: -------------------------------------------------------------------------------- 1 | export default { 2 | 3 | getRawFileAsBase64(context, file) { 4 | 5 | return new Promise((resolve, reject) => { 6 | 7 | let id = context.getters.id; 8 | 9 | let path = encodeURIComponent(file.path); 10 | 11 | let branch = encodeURIComponent(context.getters.branch); 12 | 13 | let transaction = this._vm.$db.transaction(["media"], "readonly"); 14 | 15 | let store = transaction.objectStore("media"); 16 | 17 | let request = store.get(file.path); 18 | 19 | let parts = file.path.split("."); 20 | 21 | let ext = parts[parts.length - 1]; 22 | 23 | request.onsuccess = (event) => { 24 | 25 | if (event.target.result) { 26 | 27 | resolve(URL.createObjectURL(event.target.result)); 28 | 29 | return; 30 | 31 | } 32 | 33 | this._vm.$fetch(`https://gitlab.com/api/v4/projects/${id}/repository/files/${path}/raw?ref=${branch}`) 34 | .then(response => { 35 | 36 | if (response.ok) return response.blob(); 37 | 38 | reject(response.status); 39 | 40 | }) 41 | .then(blob => { 42 | 43 | if (ext === "svg") blob = blob.slice(0, blob.size, "image/svg+xml"); 44 | 45 | let transaction = this._vm.$db.transaction(["media"], "readwrite"); 46 | 47 | let store = transaction.objectStore("media"); 48 | 49 | store.add(blob, file.path); 50 | 51 | resolve(URL.createObjectURL(blob)); 52 | 53 | }) 54 | .catch(error => { 55 | 56 | reject(error); 57 | 58 | }); 59 | 60 | }; 61 | 62 | request.onerror = function(error) { 63 | 64 | console.error(error); 65 | 66 | } 67 | 68 | }); 69 | 70 | } 71 | }; -------------------------------------------------------------------------------- /src/store/backends/gitlab/media.mutations.js: -------------------------------------------------------------------------------- 1 | export default { 2 | updateTotalMediaPages(state, totalPages) { 3 | state.media.totalPages = totalPages; 4 | }, 5 | 6 | updateMediaFiles(state, files) { 7 | state.media.files = files; 8 | }, 9 | 10 | removeMediaFile(state, index) { 11 | state.media.files.splice(index, 1); 12 | }, 13 | 14 | addMediaFile(state, file) { 15 | state.media.files.unshift(file); 16 | }, 17 | 18 | updateMediaPage(state, page) { 19 | state.media.page = page; 20 | } 21 | 22 | }; -------------------------------------------------------------------------------- /src/store/backends/gitlab/mutations.js: -------------------------------------------------------------------------------- 1 | import mediaMutations from "./media.mutations"; 2 | 3 | export default { 4 | ...mediaMutations 5 | }; 6 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import Vuex from "vuex"; 3 | import mutations from "./mutations.js"; 4 | import actions from "./actions.js"; 5 | 6 | Vue.use(Vuex); 7 | 8 | export default new Vuex.Store({ 9 | state: { 10 | user: null, 11 | collections: null, 12 | config: { 13 | config: null, 14 | loading: true, 15 | loadingError: false, 16 | error: false, 17 | message: null 18 | }, 19 | token: null 20 | }, 21 | mutations: mutations, 22 | actions: actions, 23 | getters: { 24 | 25 | loggedIn: (state) => state.token !== "" && state.token !== null && state.token !== undefined, 26 | 27 | id: (state) => encodeURIComponent(state.config.config.backend.repo), 28 | 29 | branch: (state) => state.config.config.backend.branch || "master", 30 | 31 | mediaFolder(state) { 32 | 33 | let mediaFolder = state.config.config.media_folder.startsWith("/") ? state.config.config.media_folder.substring(1) : state.config.config.media_folder; 34 | 35 | return mediaFolder.endsWith("/") ? mediaFolder : mediaFolder + "/"; 36 | 37 | }, 38 | 39 | publicFolder(state) { 40 | 41 | let publicFolder = state.config.config.public_folder.startsWith("/") ? state.config.config.public_folder : "/" + state.config.config.public_folder; 42 | 43 | return publicFolder.endsWith("/") ? publicFolder : publicFolder + "/"; 44 | 45 | } 46 | 47 | } 48 | }); 49 | -------------------------------------------------------------------------------- /src/store/mutations.js: -------------------------------------------------------------------------------- 1 | export default { 2 | loadConfig(state, config) { 3 | state.config.config = config; 4 | }, 5 | 6 | login(state, token) { 7 | state.token = token; 8 | }, 9 | 10 | logout(state) { 11 | state.token = null; 12 | localStorage.removeItem("token"); 13 | }, 14 | 15 | updateUser(state, user) { 16 | state.user = user; 17 | }, 18 | 19 | updateLoading(state, loading) { 20 | state.config.loading = loading; 21 | }, 22 | 23 | updateConfigError(state, error) { 24 | state.config.error = error; 25 | }, 26 | 27 | updateConfigErrorMessage(state, message) { 28 | state.config.message = message; 29 | }, 30 | 31 | setCollections(state, collections) { 32 | state.collections = collections; 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Muli'); 2 | 3 | 4 | @import url("https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css"); 5 | 6 | @import url("https://unpkg.com/pell/dist/pell.min.css"); 7 | 8 | :root { 9 | --grey: #f7f7f7; 10 | --red: #960000; 11 | --light-red: #913f3f; 12 | --green: #009688; 13 | --light-green: #95c6c1; 14 | --white: #fcfcfc; 15 | --black: #212121; 16 | --light-black: #313d3e; 17 | --header-border-color: #ddd; 18 | --background-color: var(--grey); 19 | --primary-button-background: var(--green); 20 | --button-color: var(--white); 21 | --button-border-radius: 3px; 22 | --button-padding: 10px 40px; 23 | --primary-button-disabled-background: var(--light-green); 24 | --danger-button-disabled-background: var(--light-red); 25 | --danger-button-background: var(--red); 26 | --header-background: var(--white); 27 | --header-height: 60px; 28 | --sidebar-width: 280px; 29 | --sidebar-background: var(--black); 30 | --link-color: var(--green); 31 | --sidebar-link-color: #bdbdbc; 32 | --sidebar-link-hover-color: var(--green); 33 | --label-background: #dddde1; 34 | --label-color: #858190; 35 | --input-border-focused-color: var(--green); 36 | --transition: all .2s linear; 37 | } 38 | 39 | * { 40 | margin: 0; 41 | padding: 0; 42 | border: none; 43 | outline: none; 44 | box-sizing: border-box; 45 | box-shadow: none; 46 | } 47 | 48 | body, 49 | html { 50 | height: 100%; 51 | } 52 | 53 | body { 54 | font-size: 16px; 55 | margin: 0; 56 | display: flex; 57 | background: var(--background-color); 58 | font-family: "Muli"; 59 | } 60 | 61 | p { 62 | text-align: justify; 63 | padding: 10px 0; 64 | text-indent: 10px; 65 | } 66 | 67 | header { 68 | border-bottom: 1px solid var(--header-border-color); 69 | padding: 0 0 20px 0; 70 | margin-bottom: 10px; 71 | display: flex; 72 | justify-content: space-between; 73 | } 74 | 75 | header h1 { 76 | color: var(--light-black); 77 | } 78 | 79 | header p { 80 | color: #798291; 81 | padding: 20px 0; 82 | } 83 | 84 | ::focus { 85 | outline: none; 86 | } 87 | 88 | ::-moz-focus-inner { 89 | border: 0; 90 | } 91 | 92 | ::selection { 93 | color: var(--white); 94 | background: var(--green); 95 | } 96 | 97 | .input { 98 | min-height: 60px; 99 | position: relative; 100 | display: flex; 101 | flex-direction: column-reverse; 102 | margin: 20px 10px; 103 | } 104 | 105 | input, select { 106 | height: 55px; 107 | border: 2px solid var(--label-background); 108 | width: 100%; 109 | border-radius: 0 3px 3px 3px; 110 | padding: 5px; 111 | font-size: 16px; 112 | } 113 | 114 | select { 115 | appearance: none; 116 | cursor: pointer; 117 | background: var(--white); 118 | } 119 | 120 | textarea { 121 | height: 155px; 122 | border: 2px solid var(--label-background); 123 | width: 100%; 124 | border-radius: 0 3px 3px 3px; 125 | padding: 5px; 126 | font-size: 16px; 127 | resize: vertical; 128 | } 129 | 130 | input:focus, textarea:focus, select:focus { 131 | border: 2px solid var(--input-border-focused-color); 132 | transition: var(--transition); 133 | } 134 | 135 | input:focus ~ label, textarea:focus ~ label, select:focus ~ label { 136 | background: var(--input-border-focused-color); 137 | color: var(--white); 138 | transition: var(--transition); 139 | } 140 | 141 | table { 142 | width: 100%; 143 | border: 1px solid var(--black); 144 | margin: 10px 0; 145 | border-collapse: collapse; 146 | } 147 | 148 | tr, td, th { 149 | padding: 10px; 150 | border: 1px solid var(--black); 151 | } 152 | 153 | code { 154 | font-weight: 700; 155 | } 156 | 157 | label { 158 | align-self: flex-start; 159 | text-transform: uppercase; 160 | background: var(--label-background); 161 | color: var(--label-color); 162 | font-size: 12px; 163 | padding: 0 5px; 164 | font-weight: 700; 165 | border-radius: 3px 3px 0 0; 166 | } 167 | 168 | pre { 169 | word-wrap: break-word; 170 | white-space: pre-wrap; 171 | padding: 10px; 172 | border-radius: 3px; 173 | } 174 | 175 | label.error { 176 | position: absolute; 177 | top: 0; 178 | right: 0; 179 | background: transparent !important; 180 | color: var(--red) !important; 181 | } 182 | 183 | .icon { 184 | display: inline-block; 185 | margin: 0 5px; 186 | width: 12px; 187 | height: 12px; 188 | } 189 | 190 | .card { 191 | background: #fdfdfd; 192 | border-radius: 5px; 193 | box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 3px 1px -2px rgba(0, 0, 0, 0.2), 194 | 0 1px 5px 0 rgba(0, 0, 0, 0.12); 195 | } 196 | 197 | .spin { 198 | animation: spin 2s infinite linear; 199 | display: inline-block; 200 | } 201 | 202 | .pad { 203 | padding: 20px; 204 | } 205 | 206 | .collection-file { 207 | margin: 20px 0; 208 | cursor: pointer; 209 | height: 60px; 210 | padding: 0 20px; 211 | line-height: 60px; 212 | text-decoration: none; 213 | color: var(--light-black); 214 | display: block; 215 | transition: var(--transition); 216 | text-transform: capitalize; 217 | } 218 | 219 | .collection-file:hover { 220 | color: var(--white); 221 | background: var(--link-color); 222 | transition: var(--transition); 223 | } 224 | 225 | .toasted.primary.success { 226 | background: var(--primary-button-background); 227 | color: var(--white); 228 | font-weight: 700; 229 | } 230 | 231 | .toasted.primary { 232 | background: var(--black); 233 | } 234 | 235 | .toasted.primary.error { 236 | background: var(--red); 237 | } 238 | 239 | .action { 240 | color: var(--link-color) !important; 241 | } 242 | 243 | .tabs-component-tabs { 244 | list-style: none; 245 | display: flex; 246 | height: 40px; 247 | justify-content: space-around; 248 | border-bottom: 1px solid var(--label-background); 249 | } 250 | 251 | .tabs-component-tabs li { 252 | width: 100%; 253 | height: 39px; 254 | line-height: 100%; 255 | text-align: center; 256 | } 257 | 258 | .tabs-component-tabs a { 259 | text-decoration: none; 260 | color: var(--label-color); 261 | font-weight: 700; 262 | text-transform: uppercase; 263 | font-size: .8em; 264 | display: inline-block; 265 | height: 39px; 266 | width: 100%; 267 | line-height: 39px; 268 | transition: var(--transition); 269 | } 270 | 271 | .tabs-component-tab:first-child a { 272 | border-radius: 5px 0 0 0; 273 | } 274 | 275 | .tabs-component-tab:last-child a { 276 | border-radius: 0 5px 0 0; 277 | } 278 | 279 | .tabs-component-tab:only-child a { 280 | border-radius: 5px 5px 0 0; 281 | } 282 | 283 | .tabs-component-tab.is-active a { 284 | color: var(--white); 285 | background: var(--sidebar-link-hover-color); 286 | transition: var(--transition); 287 | } 288 | 289 | .tabs-component-panel { 290 | padding: 10px; 291 | } 292 | 293 | .flatpickr-day.selected:hover { 294 | background: var(--green); 295 | } 296 | 297 | .flatpickr-input { 298 | margin: 0; 299 | } 300 | 301 | .flatpickr-calendar { 302 | box-shadow: 0 2px 2px 3px rgba(0, 0, 0, 0.14), 0 3px 1px -2px rgba(0, 0, 0, 0.2), 303 | 0 1px 5px 3px rgba(0, 0, 0, 0.12); 304 | } 305 | 306 | .flatpickr-day.selected { 307 | background: var(--green); 308 | } 309 | 310 | .pell { 311 | border: 2px solid var(--label-background); 312 | border-radius: 0 3px 3px 3px; 313 | transition: var(--transition); 314 | } 315 | 316 | .pell-button { 317 | width: 50px; 318 | height: 50px; 319 | font-size: 20px; 320 | } 321 | 322 | .pell-content ol, .pell-content ul { 323 | padding: 10px 40px; 324 | } 325 | 326 | .pell-content a { 327 | color: var(--link-color); 328 | text-decoration: none; 329 | } 330 | 331 | .pell:focus-within ~ label { 332 | background: var(--input-border-focused-color); 333 | color: var(--white); 334 | transition: var(--transition); 335 | } 336 | 337 | .pell:focus-within { 338 | border: 2px solid var(--input-border-focused-color); 339 | transition: var(--transition); 340 | } 341 | 342 | .pell-actionbar { 343 | border-bottom: 2px solid var(--label-background); 344 | transition: var(--transition); 345 | } 346 | 347 | #paging { 348 | text-align: center; 349 | width: 100%; 350 | list-style: none; 351 | padding: 20px 0 40px; 352 | } 353 | 354 | #paging li { 355 | display: inline-block; 356 | margin-right: 20px; 357 | } 358 | 359 | #paging a { 360 | text-decoration: none; 361 | font-weight: 700; 362 | padding: 5px 10px; 363 | color: var(--label-color); 364 | border-radius: 3px; 365 | transition: var(--transition); 366 | } 367 | 368 | #paging .router-link-exact-active, #paging a:hover { 369 | transition: var(--transition); 370 | background: var(--link-color); 371 | color: var(--white); 372 | } 373 | 374 | @keyframes spin { 375 | 0% { 376 | transform: rotate(0deg); 377 | } 378 | 379 | 100% { 380 | transform: rotate(359deg); 381 | } 382 | } -------------------------------------------------------------------------------- /src/utils/lib.js: -------------------------------------------------------------------------------- 1 | import yml from "js-yaml"; 2 | import toml from "toml-js"; 3 | import matter from "gray-matter"; 4 | 5 | function markdownParse(file) { 6 | 7 | let c = matter(file); 8 | 9 | let n = c.data; 10 | 11 | n["body"] = c.content; 12 | 13 | n = JSON.parse(JSON.stringify(n)); 14 | 15 | return n; 16 | 17 | }; 18 | 19 | export function getDataFromFile(file, type) { 20 | 21 | if (type === "yml") return yml.load(file); 22 | 23 | return new Promise((resolve, reject) => { 24 | 25 | if (type === "json") return resolve(JSON.parse(file)); 26 | 27 | if (type === "md") return resolve(markdownParse(file)); 28 | 29 | if (type === "toml") return resolve(tome.parse(file)); 30 | 31 | }); 32 | 33 | }; 34 | 35 | export function getFileFromData(data, type) { 36 | 37 | if (type === "yml") return yml.dump(data); 38 | 39 | if (type === "json") return JSON.stringify(data); 40 | 41 | if (type === "toml") return toml.dump(data); 42 | 43 | if (type === "md") { 44 | 45 | let file = data.body || ""; 46 | 47 | delete data.body; 48 | 49 | return matter.stringify(file, data); 50 | 51 | } 52 | 53 | }; 54 | 55 | export function correctWidgetNames(data) { 56 | 57 | data = data.replace(/widget\:\s*\"?select\"?/g, "widget: s-select"); 58 | 59 | data = data.replace(/widget\:\s*\"?text\"?/g, "widget: s-text"); 60 | 61 | data = data.replace(/widget\:\s*\"?object\"?/g, "widget: s-object"); 62 | 63 | data = data.replace(/widget\:\s*\"?image\"?/g, "widget: s-image"); 64 | 65 | return data; 66 | 67 | }; 68 | 69 | export function objectifyCollections(collections) { 70 | 71 | let c = {}; 72 | 73 | collections.forEach(function(collection) { 74 | c[collection.name] = collection; 75 | }); 76 | 77 | return c; 78 | 79 | }; 80 | 81 | // ensures that collections have the required 82 | // title & body fields 83 | export function collectionsHaveTitleAndBody(collections) { 84 | 85 | let allHaveTitles = true, allHaveBodies = true; 86 | 87 | loop: 88 | for (let i = 0; i < collections.length; i++) { 89 | 90 | let hasTitle = false, hasBody = false; 91 | 92 | let collection = collections[i]; 93 | 94 | if (collection.files) continue; 95 | 96 | for (let j = 0; j < collection.tabs.length; j++) { 97 | 98 | let tab = collection.tabs[j]; 99 | 100 | for (let k = 0; k < tab.fields.length; k++) { 101 | 102 | let field = tab.fields[k]; 103 | 104 | if (field.name === "title") hasTitle = true; 105 | 106 | if (field.name === "body") hasBody = true; 107 | 108 | } 109 | 110 | } 111 | 112 | if (!hasTitle || !hasBody) { 113 | allHaveTitles = false; 114 | allHaveBodies = false; 115 | break loop; 116 | } 117 | 118 | } 119 | 120 | return allHaveTitles && allHaveBodies; 121 | 122 | }; -------------------------------------------------------------------------------- /src/utils/schema.js: -------------------------------------------------------------------------------- 1 | export function getSchemaForFields(fields) { 2 | 3 | let schema = {}; 4 | 5 | fields.forEach(field => { 6 | 7 | let s = {}; 8 | 9 | // since json schema definition doesn't allow 10 | // setting defaults in oneOf 11 | // make sure a field is required unless 12 | // explicitly stated otherwise 13 | if (field.required || field.required === undefined) { 14 | 15 | if (field.widget !== "list") { 16 | 17 | s["presence"] = { 18 | message: `${field.label} is required`, 19 | allowEmpty: false 20 | } 21 | 22 | } else { 23 | 24 | s["presence"] = { 25 | message: `${field.label} are required`, 26 | allowEmpty: false 27 | } 28 | 29 | } 30 | 31 | }; 32 | 33 | if (field.widget !== "number") { 34 | 35 | if (field.max || field.min) s["length"] = {}; 36 | 37 | if (field.max) { 38 | s["length"].maximum = field.max; 39 | s["length"].tooLong = `${field.label} must be less or equal to ${field.max}`; 40 | } 41 | 42 | if (field.min) { 43 | s["length"].minimum = field.min; 44 | s["length"].tooShort = `${field.label} must be greater or equal to ${field.min}` 45 | } 46 | 47 | if (field.pattern) s["format"] = { 48 | pattern: field.pattern, 49 | message: `Must be a valid ${field.label}` 50 | }; 51 | 52 | } else { 53 | 54 | if (field.max || field.min) s["numericality"] = {}; 55 | 56 | if (field.max) { 57 | s["numericality"].lessThanOrEqualTo = field.max; 58 | s["numericality"].notLessThanOrEqualTo = `${field.label} must be less than or equal to ${field.max}`; 59 | } 60 | 61 | if (field.min) { 62 | s["numericality"].greaterThanOrEqualTo = field.min; 63 | s["numericality"].notGreaterThanOrEqualTo = `${field.label} must be greater than or equal to ${field.min}`; 64 | } 65 | 66 | } 67 | 68 | // recursively generate nested validation 69 | // schema for object widgets 70 | if (field.widget === "s-object") { 71 | 72 | let objectSchema = getSchemaForFields(field.fields); 73 | 74 | for (let s in objectSchema) { 75 | 76 | let name = field.name + "." + s; 77 | 78 | schema[name] = objectSchema[s]; 79 | 80 | } 81 | 82 | } 83 | 84 | schema[field.name] = s; 85 | 86 | }); 87 | 88 | return schema; 89 | 90 | }; 91 | 92 | export function getDefaultDataForFields(fields) { 93 | 94 | let data = {}; 95 | 96 | fields.forEach(field => { 97 | 98 | data[field.name] = field.default; 99 | 100 | }); 101 | 102 | return data; 103 | 104 | }; -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | productionSourceMap: false, 3 | 4 | filenameHashing: false, 5 | 6 | runtimeCompiler: false, 7 | 8 | css: { 9 | extract: false 10 | }, 11 | 12 | baseUrl: "/admin/", 13 | 14 | configureWebpack: { 15 | output: { 16 | filename: "saleina-cms.min.js" 17 | } 18 | }, 19 | 20 | chainWebpack: config => { 21 | 22 | config.optimization.delete("splitChunks"); 23 | 24 | const svgRule = config.module.rule('svg'); 25 | 26 | svgRule.uses.clear(); 27 | 28 | svgRule 29 | .use("vue-svg-loader") 30 | .loader("vue-svg-loader"); 31 | 32 | } 33 | 34 | } -------------------------------------------------------------------------------- /website/README.md: -------------------------------------------------------------------------------- 1 | # Saleina CMS Website & Docs 2 | 3 | This directory builds saleinacms.org. If you'd like to propose changes to the site or docs, you'll find the source files in here. 4 | 5 | ## Local development 6 | 7 | The site is built with [Hugo](https://gohugo.io/). 8 | 9 | To run the site locally, you'll need to have [Node](https://nodejs.org) and [Hugo](https://gohugo.io/) installed on your computer. 10 | 11 | From your terminal window, `cd` into the `website` directory of the repo, and run 12 | 13 | ```bash 14 | ../node_modules/.bin/stylus --include ../node_modules/nib/lib/ --watch static/css/ 15 | hugo server 16 | ``` 17 | 18 | Then visit http://localhost:1313/ - Hugo will automatically refresh the page when stylesheets or content changes. 19 | -------------------------------------------------------------------------------- /website/config.toml: -------------------------------------------------------------------------------- 1 | baseURL = "" 2 | 3 | languageCode = "en-us" 4 | 5 | relativeurls = true 6 | 7 | disableKinds = ["RSS"] 8 | 9 | pygmentsCodeFences = true 10 | 11 | [blackfriday] 12 | extensions = ["hardLineBreak"] 13 | 14 | [permalinks] 15 | tags = "/blog/tags/:slug:/" 16 | categories = "/blog/categories/:slug:/" -------------------------------------------------------------------------------- /website/content/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | --- -------------------------------------------------------------------------------- /website/content/blog/webhooks.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Webhooks have arrived 3 | --- 4 | The concept of a WebHook is simple. A WebHook is an HTTP callback: an HTTP request that occurs when something happens; a simple event-notification via HTTP. 5 | 6 | A web application implementing WebHooks will make a request to a URL when certain things happen. When a web application enables users to register their own URLs, the users can then extend, customize, and integrate that application with their own custom extensions or even with other applications around the web. For the user, WebHooks are a way to receive valuable information when it happens, rather than continually polling for that data and receiving nothing valuable most of the time. WebHooks have enormous potential and are limited only by your imagination! (No, it can't wash the dishes. Yet.) 7 | 8 | That is why we are so glad to announce **Saleina CMS** will now notify a URL of your choice when certain actions are performed by a user. You can read more about it in the [Docs](/docs/configuration-options/#hooks). -------------------------------------------------------------------------------- /website/content/docs/add-to-your-site.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Add to Your Site 3 | weight: 20 4 | group: start 5 | --- 6 | 7 | Saleina CMS is adaptable to a wide variety of projects. It works with any content written in markdown, JSON, YAML, or TOML files, stored in a repo on [GitLab](https://about.gitlab.com/). You can also create your own custom backend. 8 | 9 | This tutorial will guide you through the steps for adding Saleina CMS to a site that's built with a common [static site generator](https://www.staticgen.com/), like Jekyll, Hugo, Hexo, or Gatsby. 10 | " 11 | 12 | ## App File Structure 13 | 14 | All Saleina CMS files are contained in a static `admin` folder, stored at the root of your published site. Where you store this folder in the source files depends on your static site generator. Here's the static file location for a few of the most popular static site generators: 15 | 16 | | These generators ... | store static files in | 17 | | ----------------------- | --------------------- | 18 | | Jekyll, GitBook | `/` (project root) | 19 | | Hugo, Gatsby, Nuxt | `/static` | 20 | | Hexo, Middleman, Jigsaw | `/source` | 21 | | Spike | `/views` | 22 | | Wyam | `/input` | 23 | 24 | If your generator isn't listed here, you can check its documentation, or as a shortcut, look in your project for a `css` or `images` folder. The contents of folders like that are usually processed as static files, so it's likely you can store your `admin` folder next to those. (When you've found the location, feel free to add it to these docs by [filing a pull request](https://github.com/saleina/saleinacms/blob/master/CONTRIBUTING.md)!) 25 | 26 | Inside the `admin` folder, you'll create two files: 27 | 28 | ```x 29 | admin 30 | ├ index.html 31 | └ config.yml 32 | ``` 33 | 34 | The first file, `admin/index.html`, is the entry point for the Saleina CMS admin interface. This means that users can navigate to `yoursite.com/admin/` to access it. On the code side, it's a basic HTML starter page that loads the Saleina CMS JavaScript file. In this example, we pull the file from a public CDN: 35 | 36 | ```html 37 | 38 | 39 | 40 | 41 | 42 | 43 | SaleinaCMS 44 | 45 | 46 | 49 |
50 | 51 | 52 | 53 | ``` 54 | 55 | The second file, `admin/config.yml`, is the heart of your Saleina CMS installation, and a bit more complex. The [Configuration](#configuration) section covers the details. 56 | 57 | ## Configuration 58 | 59 | Configuration will be different for every site, so we'll break it down into parts. All code snippets in this section will be added to your `admin/config.yml` file. 60 | 61 | ### Backend 62 | 63 | We're using [Gitlab](https://www.gitlab.com) for our hosting and authentication in this tutorial, so backend configuration is fairly straightforward. 64 | 65 | For GitLab repositories, you can start your Saleina CMS `config.yml` file with these lines: 66 | 67 | ```yaml 68 | backend: 69 | name: gitlab 70 | branch: master # Branch to update (optional; defaults to master) 71 | ``` 72 | 73 | The configuration above specifies your backend protocol and your publication branch. If you leave out the `branch` declaration, it will default to `master`. 74 | 75 | ### Media and Public Folders 76 | 77 | Saleina CMS allows users to upload images directly within the editor. For this to work, the CMS needs to know where to save them. If you already have an `images` folder in your project, you could use its path, possibly creating an `uploads` sub-folder, for example: 78 | 79 | ```yaml 80 | # This line should *not* be indented 81 | media_folder: "images/uploads" # Media files will be stored in the repo under images/uploads 82 | ``` 83 | 84 | If you're creating a new folder for uploaded media, you'll need to know where your static site generator expects static files. You can refer to the paths outlined above in [App File Structure](#app-file-structure), and put your media folder in the same location where you put the `admin` folder. 85 | 86 | Note that the`media_folder` file path is relative to the project root, so the example above would work for Jekyll, GitBook, or any other generator that stores static files at the project root. However, it would not work for Hugo, Hexo, Middleman or others that store static files in a subfolder. Here's an example that could work for a Hugo site: 87 | 88 | ```yaml 89 | # These lines should *not* be indented 90 | media_folder: "static/images/uploads" # Media files will be stored in the repo under static/images/uploads 91 | public_folder: "/images/uploads" # The src attribute for uploaded media will begin with /images/uploads 92 | ``` 93 | 94 | The configuration above adds a new setting, `public_folder`. While `media_folder` specifies where uploaded files will be saved in the repo, `public_folder` indicates where they can be found in the published site. This path is used in image `src` attributes and is relative to the file where it's called. For this reason, we usually start the path at the site root, using the opening `/`. 95 | 96 | *If `public_folder` is not set, Saleina CMS will default to the same value as `media_folder`, adding an opening `/` if one is not included.* 97 | 98 | 99 | ### Collections 100 | 101 | Collections define the structure for the different content types on your static site. Since every site is different, the `collections` settings will be very different from one site to the next. 102 | 103 | Let's say your site has a blog, with the posts stored in `_posts/blog`, and files saved in a date-title format, like `1999-12-31-lets-party.md`. Each post begins with settings in yaml-formatted front matter, like so: 104 | 105 | ```yaml 106 | --- 107 | layout: blog 108 | title: "Let's Party" 109 | date: 1999-12-31 11:59:59 -0800 110 | thumbnail: "/images/prince.jpg" 111 | rating: 5 112 | --- 113 | 114 | This is the post body, where I write about our last chance to party before the Y2K bug destroys us all. 115 | ``` 116 | 117 | Given this example, our `collections` settings would look like this: 118 | 119 | ```yaml 120 | collections: 121 | - name: "blog" # Used in routes, e.g., /admin/collections/blog 122 | label: "Blog" # Used in the UI 123 | folder: "content/blog" # The path to the folder where the documents are stored 124 | slug: "{{year}}-{{month}}-{{day}}-{{slug}}" # Filename template, e.g., YYYY-MM-DD-title.md 125 | tabs: 126 | - label: Basic 127 | fields: # The fields for each document, usually in front matter 128 | - {label: "Title", name: "title", widget: "string"} 129 | - {label: "Publish Date", name: "date", widget: "datetime"} 130 | - {label: "Featured Image", name: "thumbnail", widget: "image"} 131 | - {label: "Rating (scale of 1-5)", name: "rating", widget: "number"} 132 | 133 | - label: Body 134 | fields: 135 | - {label: "Body", name: "body", widget: "markdown"} 136 | ``` 137 | 138 | Let's break that down: 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 172 | 173 |
namePost type identifier, used in routes. Must be unique.
labelWhat the post type will be called in the admin UI.
folderWhere files of this type are stored, relative to the repo root.
slugTemplate for filenames. {{year}}, {{month}}, and {{day}} will pull from the post's date field or save date. {{slug}} is a url-safe version of the post's title. Default is simply {{slug}}.
tabstabs listed here are shown as tabs in the cotent editor. Each tab contains the following properties: 160 |
    161 |
  • label: Tab label in the editor UI.
  • 162 |
  • fields: Fields listed here are shown as fields in the content editor, then saved as front matter at the beginning of the document (except for body, which follows the front matter). Each field contains the following properties: 163 |
      164 |
    • label: Field label in the editor UI.
    • 165 |
    • name: Field name in the document front matter.
    • 166 |
    • widget: Determines UI style and value data type (details below).
    • 167 |
    • default (optional): Sets a default value for the field.
    • 168 |
    169 |
  • 170 |
171 |
174 | 175 | As described above, the `widget` property specifies a built-in or custom UI widget for a given field. When a content editor enters a value into a widget, that value will be saved in the document front matter as the value for the `name` specified for that field. A full listing of available widgets can be found in the [Widgets doc](/docs/widgets). 176 | 177 | Based on this example, you can go through the post types in your site and add the appropriate settings to your Saleina CMS `config.yml` file. Each post type should be listed as a separate node under the `collections` field. 178 | 179 | ## Accessing the CMS 180 | 181 | Your site CMS is now fully configured and ready for login! 182 | 183 | You can access your site's CMS at `yoursite.com/admin/`. 184 | 185 | That's All Folks! 186 | -------------------------------------------------------------------------------- /website/content/docs/backends.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Backends 3 | weight: 25 4 | group: start 5 | --- 6 | 7 | Saleina CMS stores content in your GitLab repository. In order for this to work, you need to authenticate with your Git host. We have a few options for handling this. 8 | 9 | ## GitLab Backend 10 | 11 | For repositories stored on GitLab, the `gitlab` backend allows CMS users to log in directly with their GitLab account. Note that all users must have push access to your content repository for this to work. 12 | 13 | The GitLab API allows for two types of OAuth2 flows: [Web Application Flow](https://docs.gitlab.com/ce/api/oauth2.html#web-application-flow), and [Implicit Grant](https://docs.gitlab.com/ce/api/oauth2.html#implicit-grant), which operates _without_ the need for an authentication server and which is the one Saleina CMS uses. 14 | 15 | ### Client-Side Implicit Grant 16 | 17 | With GitLab's Implicit Grant, users can authenticate with GitLab directly from the client. To do this: 18 | 19 | 1. Follow the [GitLab docs](https://docs.gitlab.com/ee/integration/oauth_provider.html#adding-an-application-through-the-profile) to add your Saleina CMS instance as an OAuth application. For the **Redirect URI**, enter the address where you access Saleina CMS, for example, `https://www.example.com/admin/`. For scope, select `api`. 20 | 2. GitLab will give you an **Application ID**. Copy this and enter it in your Saleina CMS `config.yml` file, along with the following settings: 21 | 22 | ```yaml 23 | backend: 24 | name: gitlab 25 | repo: owner-name/repo-name # Path to your GitLab repository 26 | client_id: your-app-id # Application ID from your GitLab settings 27 | ``` 28 | 29 | GitLab will also provide you with a client secret. You should _never_ store this in your repo or reveal it in the client. -------------------------------------------------------------------------------- /website/content/docs/boolean.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Boolean 3 | group: widgets 4 | --- 5 | 6 | The boolean widget translates a toggle switch input to a true/false value. 7 | 8 | - **Name:** `boolean` 9 | - **UI:** toggle switch 10 | - **Data type:** boolean 11 | - **Options:** 12 | - `default`: accepts `true` or `false`; defaults to `false` 13 | - **Example:** 14 | 15 | ```yaml 16 | - {label: "Draft", name: "draft", widget: "boolean", default: true} 17 | ``` 18 | -------------------------------------------------------------------------------- /website/content/docs/collection-types.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Collection Types 3 | weight: 27 4 | group: start 5 | --- 6 | 7 | All editable content types are defined in the `collections` field of your `config.yml` file, and displayed in the left sidebar of the editor UI. 8 | 9 | Collections come in two main types: `folder` and `files`. 10 | 11 | ## Folder collections 12 | 13 | Folder collections represent one or more files with the same format, fields, and configuration options, all stored within the same folder in the repository. You might use a folder collection for blog posts, product pages, author data files, etc. 14 | 15 | Unlike file collections, folder collections have the option to allow editors to create new items in the collection. This is set by the boolean `create` field. 16 | 17 | **Note:** Folder collections must have at least one field with the name "title" for creating new entry slugs. That field should use the default "string" widget. The "label" for the field can be any string value. 18 | 19 | Example: 20 | 21 | ```yaml 22 | - label: "Blog" 23 | name: "blog" 24 | folder: "content/blog" 25 | tabs: 26 | - label: Basic 27 | fields: 28 | - {label: "Title", name: "title", widget: "string"} 29 | - {label: "Publish Date", name: "date", widget: "datetime"} 30 | - {label: "Featured Image", name: "thumbnail", widget: "image"} 31 | - label: Body 32 | fields: 33 | - {label: "Body", name: "body", widget: "markdown"} 34 | ``` 35 | 36 | ## File collections 37 | 38 | A `files` collection contains one or more uniquely configured files. Unlike items in `folder` collections, which repeat the same configuration over all files in the folder, each item in a `files` collection has an explicitly set path, filename, and configuration. This can be useful for unique files with a custom set of fields, like a settings file or a custom landing page with a unique content structure. 39 | 40 | When configuring a `files` collection, each file in the collection is configured separately, and listed under the `files` field of the collection. Each file has its own list of `tabs` and `fields`, and a unique filepath specified in the `file` field (relative to the base of the repo). 41 | 42 | **Note:** Files listed in a file collection must already exist in the repo, and must have a valid value for the file type. For example, an empty file works as valid YAML, but a JSON file must have a non-empty value to be valid, such as an empty object. 43 | 44 | Example: 45 | 46 | ```yaml 47 | - label: "Pages" 48 | name: "pages" 49 | files: 50 | - label: "Home" 51 | name: "home" 52 | file: "content/_index.md" 53 | tabs: 54 | - label: "Basic" 55 | fields: 56 | - {label: "Title", name: "title", widget: "string"} 57 | - {label: "Intro", name: "intro", widget: "markdown"} 58 | 59 | - label: "Team" 60 | fields: 61 | - label: "Team" 62 | name: "team" 63 | widget: "list" 64 | fields: 65 | - {label: "Name", name: "name", widget: "string"} 66 | - {label: "Position", name: "position", widget: "string"} 67 | - {label: "Photo", name: "photo", widget: "image"} 68 | ``` 69 | -------------------------------------------------------------------------------- /website/content/docs/configuration-options.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Configuration Options 3 | weight: 23 4 | group: reference 5 | --- 6 | 7 | All configuration options for Saleina CMS are specified in a `config.yml` file, in the folder where you access the editor UI (usually in the `/admin` folder). 8 | 9 | To see working configuration examples check out the [CMS demo site](https://demo.saleinacms.org). (No login required: click the login button and the CMS will open.) You can refer to the demo [configuration code](https://github.com/saleina/saleinacms/blob/master/public/config.yml) to see how each option was configured. 10 | 11 | You can find details about all configuration options below. Note that [YAML syntax](https://en.wikipedia.org/wiki/YAML#Basic_components) allows lists and objects to be written in block or inline style, and the code samples below include a mix of both. 12 | 13 | 14 | ## Backend 15 | 16 | *This setting is required.* 17 | 18 | The `backend` option specifies how to access the content for your site, including authentication. 19 | 20 | ## Logo 21 | 22 | The `logo` option specifies a custom logo to display in the editor UI. It expects a url to an image file. 23 | 24 | **Example** 25 | ```yaml 26 | logo: "http://example.com/logo.png" 27 | ``` 28 | 29 | ## Media and Public Folders 30 | 31 | *This setting is required.* 32 | 33 | Saleina CMS users can upload files to your repository using the Media Gallery. The following settings specify where these files are saved, and where they can be accessed on your built site. 34 | 35 | **Options** 36 | 37 | - `media_folder` (required): Folder path where uploaded files should be saved, relative to the base of the repo. 38 | - `public_folder` (optional): Folder path where uploaded files will be accessed, relative to the base of the built site. For fields controlled by [file] or [image] widgets, the value of the field is generated by prepending this path to the filename of the selected file. Defaults to the value of `media_folder`, with an opening `/`. 39 | 40 | **Example** 41 | 42 | ``` yaml 43 | media_folder: "static/images/uploads" 44 | public_folder: "/images/uploads" 45 | ``` 46 | 47 | Based on the settings above, if a user used an image widget field called `avatar` to upload and select an image called `philosoraptor.png`, the image would be saved to the repository at `/static/images/uploads/philosoraptor.png`, and the `avatar` field for the file would be set to `/images/uploads/philosoraptor.png`. 48 | 49 | ## Collections 50 | 51 | *This setting is required.* 52 | 53 | The `collections` setting is the heart of your Saleina CMS configuration, as it determines how content types and editor fields in the UI generate files and content in your repository. Each collection you configure displays in the left sidebar of the Content page of the editor UI, in the order they are entered into your Saleina CMS `config.yml` file. 54 | 55 | `collections` accepts a list of collection objects, each with the following options: 56 | 57 | - `name` (required): unique identifier for the collection, used as the key when referenced in other contexts. 58 | - `Label`: label for the collection in the editor UI; defaults to the value of `name` 59 | - `file` or `folder` (requires one of these): specifies the collection type and location; details in [Collection Types](/docs/collection-types) 60 | - `create`: for `folder` collections only; `true` allows users to create new items in the collection; defaults to `false` 61 | - `delete`: `false` prevents users from deleting items in a collection; defaults to `true` 62 | - `hooks`: see detailed description below 63 | - `type`: see detailed description below 64 | - `slug`: see detailed description below 65 | - `tabs` (required): see detailed description below 66 | 67 | The last few options require more detailed information. 68 | 69 | ### `hooks` 70 | 71 | Hooks allow you to be notified when certain actions occur in Saleina CMS, it allows limitless integrations and possibilities. 72 | 73 | `hooks` accepts the following properties 74 | 75 | - `created`: Accepts a url to be notified when a new item is created in a folder collection, it sends the created data with a `POST` method 76 | 77 | - `updated`: Accepts a url to be notified when an item in a file or folder collection is updated, it sends the updated data with a `PUT` method 78 | 79 | - `deleted`: Accepts a url to be notified when an item in a file or folder collection is deleted, it sends the deleted data with `DELETE` method. 80 | 81 | **Example:** 82 | 83 | ```yaml 84 | collections: 85 | - label: "Blog" 86 | name: "blog" 87 | folder: "website/content/blog" 88 | delete: true 89 | hooks: 90 | created: "https://example.com/blog/" # url to be called when a new blog post is created 91 | updated: "https://example.com/blog/" # url to be called when a blog post is updated 92 | deleted: "https://example.com/blog/" # url to be called when a blog post is deleted 93 | tabs: 94 | - label: "Basic" 95 | fields: 96 | - {label: "Draft", name: "draft", widget: "boolean", default: true} 97 | - {label: "Title", name: "title", widget: "string"} 98 | - {label: "Publish Date", name: "date", widget: "datetime", format: "Z"} 99 | - label: "Body" 100 | fields: 101 | - {label: "Body", name: "body", widget: "markdown"} 102 | ``` 103 | 104 | **Example data:** 105 | 106 | ```json 107 | { 108 | "path": "data/settings.json", 109 | "content": "{}", 110 | "branch": "master" 111 | } 112 | ``` 113 | 114 | ### `type` 115 | 116 | These setting determines how collection files are parsed and saved. It's optional and by default Saleina CMS will assume `md`. If your collection contains a different file type or you'd like more control, you can set these field explicitly. 117 | 118 | ### `slug` 119 | 120 | For folder collections where users can create new items, the `slug` option specifies a template for generating new filenames based on a file's creation date and `title` field. (This means that all collections with `create: true` must have a `title` field.) 121 | 122 | **Available template tags:** 123 | 124 | - `{{slug}}`: a url-safe version of the `title` field for the file 125 | - `{{year}}`: 4-digit year of the file creation date 126 | - `{{month}}`: 2-digit month of the file creation date 127 | - `{{day}}`: 2-digit day of the month of the file creation date 128 | - `{{hour}}`: 2-digit hour of the file creation date 129 | - `{{minute}}`: 2-digit minute of the file creation date 130 | - `{{second}}`: 2-digit second of the file creation date 131 | 132 | **Example:** 133 | 134 | ```yaml 135 | slug: "{{year}}-{{month}}-{{day}}_{{slug}}" 136 | ``` 137 | 138 | ### `tabs` 139 | The `tabs` option maps a collection of fields to editor tabs. The order of the tabs in your Saleina CMS `config.yml` file determines their order in the editor UI and in the saved file. 140 | 141 | `tabs` accepts the following proprties 142 | 143 | - `label` (required): label for the tab in the editor UI. 144 | - `fields` (required): option maps editor UI widgets to field-value pairs in the saved file, the order of the fields in your Saleina CMS `config.yml` file determines their order in the editor UI and in the saved file. 145 | 146 | `fields` accepts a list of collection objects, each with the following options: 147 | 148 | - `name` (required): unique identifier for the field, used as the key when referenced in other contexts. 149 | - `label`: label for the field in the editor UI; defaults to the value of `name` 150 | - `widget`: defines editor UI and inputs and file field data types; details in [Widgets](/docs/widgets) 151 | - `default`: specify a default value for a field; available for most widget types (see [Widgets](/docs/widgets) for details on each widget type) 152 | - `required`: specify as `false` to make a field optional; defaults to `true` 153 | - `pattern`: add field validation by specifying a string with a regex pattern. 154 | 155 | In files with frontmatter, one field should be named `body`. This special field represents the section of the document (usually markdown) that comes after the frontmatter. 156 | 157 | **Example:** 158 | 159 | ```yaml 160 | tabs: 161 | - label: Basic 162 | fields: 163 | - {label: "Title", name: "title", widget: "string", pattern: ".{20,}"} 164 | - {label: "Layout", name: "layout", widget: "hidden", default: "blog"} 165 | - label: Body 166 | fields: 167 | - {label: "Featured Image", name: "thumbnail", widget: "image", required: false} 168 | - {label: "Body", name: "body", widget: "markdown"} 169 | ``` -------------------------------------------------------------------------------- /website/content/docs/date.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Date 3 | group: widgets 4 | --- 5 | 6 | The date widget translates a date picker input to a date string. For saving date and time together, use the datetime widget. 7 | 8 | - **Name:** `date` 9 | - **UI:** date picker 10 | - **Data type:** Flatpickr formatted date string 11 | - **Options:** 12 | - `default`: accepts a date string, or an empty string to accept blank input; otherwise defaults to current date 13 | - `format`: optional; accepts Flatpickr.js [tokens](https://flatpickr.js.org/formatting/); defaults to raw Date object (if supported by output format) 14 | - **Example:** 15 | 16 | ```yaml 17 | - label: "Birthdate" 18 | name: "birthdate" 19 | widget: "date" 20 | default: "" 21 | format: "Y/m/d" 22 | ``` 23 | -------------------------------------------------------------------------------- /website/content/docs/datetime.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Datetime 3 | group: widgets 4 | --- 5 | 6 | The datetime widget translates a datetime picker to a datetime string. For saving the date only, use the date widget. 7 | 8 | - **Name:** `datetime` 9 | - **UI:** datetime picker 10 | - **Data type:** Flatpickr formatted datetime string 11 | - **Options:** 12 | - `default`: accepts a datetime string, or an empty string to accept blank input; otherwise defaults to current datetime 13 | - `format`: optional; accepts Flatpickr.js [tokens](https://flatpickr.js.org/formatting/); defaults to raw Date object (if supported by output format) 14 | - **Example:** 15 | 16 | ```yaml 17 | - label: "Start time" 18 | name: "start" 19 | widget: "datetime" 20 | format: "Y-m-d h:i K" 21 | ``` 22 | -------------------------------------------------------------------------------- /website/content/docs/file.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: File 3 | group: widgets 4 | --- 5 | 6 | The file widget allows editors to upload a file or select an existing one from the media library. The path to the file will be saved to the field as a string. 7 | 8 | - **Name:** `file` 9 | - **UI:** file picker button opens media gallery allowing to pick files; displays selected file name 10 | - **Data type:** file path string, based on `media_folder`/`public_folder` configuration 11 | - **Options:** 12 | - `default`: accepts a file path string; defaults to null 13 | - **Example:** 14 | 15 | ```yaml 16 | - label: "Document" 17 | name: "document" 18 | widget: "file" 19 | default: "/uploads/doc.pdf" 20 | ``` 21 | -------------------------------------------------------------------------------- /website/content/docs/hidden.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Hidden 3 | group: widgets 4 | --- 5 | 6 | Hidden widgets do not display in the UI. In folder collections that allow users to create new items, you will often want to set a default for hidden fields, so they will be set without requiring an input. 7 | 8 | - **Name:** `hidden` 9 | - **UI:** none 10 | - **Data type:** any valid data type 11 | - **Options:** 12 | - `default`: accepts any valid data type; recommended for collections that allow adding new items 13 | - **Example:** 14 | 15 | ```yaml 16 | - {label: "Layout", name: "layout", widget: "hidden", default: "blog"} 17 | ``` 18 | -------------------------------------------------------------------------------- /website/content/docs/image.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Image 3 | group: widgets 4 | --- 5 | 6 | The image widget allows editors to upload an image or select an existing one from the media library. The path to the image file will be saved to the field as a string. 7 | 8 | - **Name:** `image` 9 | - **UI:** file picker button opens media gallery allowing image files (jpg, jpeg, gif, png, bmp, svg) only; displays selected image thumbnail 10 | - **Data type:** file path string, based on `media_folder`/`public_folder` configuration 11 | - **Options:** 12 | - `default`: accepts a file path string; defaults to null 13 | - **Example:** 14 | 15 | ```yaml 16 | - label: "Featured Image" 17 | name: "thumbnail" 18 | widget: "image" 19 | default: "/uploads/chocolate-dogecoin.jpg" 20 | ``` 21 | -------------------------------------------------------------------------------- /website/content/docs/introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introduction 3 | weight: 1 4 | group: start 5 | --- 6 | 7 | Saleina CMS is an open source content management system for your Git workflow that enables you to provide editors with friendly UI and intuitive workflow. You can use it with any static site generator to create faster, more flexible web projects. Content is stored in your Git repository alongside your code for easier versioning, multi-channel publishing, and the option to handle content updates directly in Git. 8 | 9 | At its core, Saleina CMS is an open-source Vue app that acts as a wrapper for the Git workflow, using the GitLab API. This provides many advantages, including: 10 | 11 | * **Fast, web-based UI:** with tabbed UI, rich-text editing, and media uploads. 12 | * **Platform agnostic:** works with most static site generators. 13 | * **Easy installation:** add two files to your site and hook up the backend by linking to our CDN. 14 | * **Modern authentication:** using GitLab. 15 | * **Flexible content types:** specify an unlimited number of content types with custom fields. 16 | 17 | ## Find out more 18 | 19 | - Configure your existing site by following a [tutorial](/docs/add-to-your-site/) or checking [configuration options](/docs/configuration-options). 20 | - Ask questions and share ideas in the Saleina CMS community on [Telegram](tg://resolve?domain=saleinacmsdiscussions). 21 | -------------------------------------------------------------------------------- /website/content/docs/list.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: List 3 | group: widgets 4 | --- 5 | 6 | The list widget allows you to create a repeatable item in the UI which saves as a list of widget values. map a user-provided string with a comma delimiter into a list. You can choose any widget as a child of a list widget—even other lists. 7 | 8 | - **Name:** `list` 9 | - **UI:** if `fields` is specified, field containing a repeatable child widget, with controls for adding, and deleting widgets; if unspecified, a text input for entering comma-separated values 10 | - **Data type:** list of widget values 11 | - **Options:** 12 | - `default`: if `fields` is specified, declare defaults on the child widgets; if not, you may specify a list of strings to populate the text field 13 | - `max`: limits the number of items in the list 14 | - `fields`: a nested list of multiple widget fields to be included in each repeatable iteration 15 | - **Example** (`fields` not specified): 16 | 17 | ```yaml 18 | - label: "Tags" 19 | name: "tags" 20 | widget: "list" 21 | default: ["news"] 22 | ``` 23 | 24 | - **Example** (with `fields`): 25 | 26 | ```yaml 27 | - label: "Testimonials" 28 | name: "testimonials" 29 | widget: "list" 30 | fields: 31 | - {label: Quote, name: quote, widget: string, default: "Everything is awesome!"} 32 | - label: Author 33 | name: author 34 | widget: object 35 | fields: 36 | - {label: Name, name: name, widget: string, default: "Emmet"} 37 | - {label: Avatar, name: avatar, widget: image, default: "/img/emmet.jpg"} 38 | ``` 39 | -------------------------------------------------------------------------------- /website/content/docs/markdown.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Markdown 3 | group: widgets 4 | --- 5 | 6 | The markdown widget provides a full fledged text editor that allows users to format text with features such as headings and blockquotes. 7 | 8 | *Please note:* in case you want to use your markdown editor to fill a markdown's file content after the frontmatter, you'll have name the field as `body` so then the CMS can recognize it and save the file accordingly. 9 | 10 | - **Name:** `markdown` 11 | - **UI:** full text editor 12 | - **Data type:** markdown 13 | - **Options:** 14 | - `default`: accepts markdown content 15 | - **Example:** 16 | 17 | ```yaml 18 | - {label: "Blog post content", name: "body", widget: "markdown"} 19 | ``` -------------------------------------------------------------------------------- /website/content/docs/number.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Number 3 | group: widget 4 | --- 5 | 6 | The number widget uses an HTML number input, saving the value as a string, integer, or floating point number. 7 | 8 | - **Name:** `number` 9 | - **UI:** HTML [number input](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/number) 10 | - **Data type:** string by default; configured by `valueType` option 11 | - **Options:** 12 | - `default`: accepts string or number value; defaults to empty string 13 | - `valueType`: accepts `int` or `float`; any other value results in saving as a string 14 | - `min`: accepts a number for minimum value accepted; unset by default 15 | - `max`: accepts a number for maximum value accepted; unset by default 16 | - **Example:** 17 | 18 | ```yaml 19 | - label: "Puppy Count" 20 | name: "puppies" 21 | widget: "number" 22 | default: 2 23 | valueType: "int" 24 | min: 1 25 | max: 101 26 | ``` 27 | -------------------------------------------------------------------------------- /website/content/docs/object.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Object 3 | group: widgets 4 | --- 5 | 6 | The object widget allows you to group multiple widgets together, nested under a single field. You can choose any widget as a child of an object widget—even other objects. 7 | 8 | - **Name:** `object` 9 | - **UI:** a field containing one or more child widgets 10 | - **Data type:** list of child widget values 11 | - **Options:** 12 | - `default`: you can set defaults within each sub-field's configuration 13 | - `fields`: (**required**) a nested list of widget fields to include in your widget 14 | - **Example:** 15 | 16 | ```yaml 17 | - label: "Profile" 18 | name: "profile" 19 | widget: "object" 20 | fields: 21 | - {label: "Public", name: "public", widget: "boolean", default: true} 22 | - {label: "Name", name: "name", widget: "string"} 23 | - label: "Birthdate" 24 | name: "birthdate" 25 | widget: "date" 26 | default: "" 27 | format: "MM/DD/YYYY" 28 | - label: "Address" 29 | name: "address" 30 | widget: "object" 31 | fields: 32 | - {label: "Street Address", name: "street", widget: "string"} 33 | - {label: "City", name: "city", widget: "string"} 34 | - {label: "Postal Code", name: "post-code", widget: "string"} 35 | ``` 36 | -------------------------------------------------------------------------------- /website/content/docs/select.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Select 3 | group: widgets 4 | --- 5 | 6 | The select widget allows you to pick a single string value from a dropdown menu. 7 | 8 | - **Name:** `select` 9 | - **UI:** HTML select input 10 | - **Data type:** string 11 | - **Options:** 12 | - `default`: accepts a string; defaults to an empty string 13 | - `options`: (**required**) a list of options for the dropdown menu; can be listed in two ways: 14 | - string values: the label displayed in the dropdown is the value saved in the file 15 | - object with `label` and `value` fields: the label displays in the dropdown; the value is saved in the file 16 | - **Example** (options as strings): 17 | 18 | ```yaml 19 | - label: "Align Content" 20 | name: "align" 21 | widget: "select" 22 | options: ["left", "center", "right"] 23 | ``` 24 | - **Example** (options as objects): 25 | 26 | ```yaml 27 | - label: "City" 28 | name: "airport-code" 29 | widget: "select" 30 | options: 31 | - { label: "Chicago", value: "ORD" } 32 | - { label: "Paris", value: "CDG" } 33 | - { label: "Tokyo", value: "HND" } 34 | ``` 35 | 36 | -------------------------------------------------------------------------------- /website/content/docs/string.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: String 3 | group: widgets 4 | --- 5 | 6 | The string widget translates a basic text input to a string value. For larger textarea inputs, use the text widget. 7 | 8 | - **Name:** `string` 9 | - **UI:** text input 10 | - **Data type:** string 11 | - **Options:** 12 | - `default`: accepts a string; defaults to an empty string 13 | - **Example:** 14 | 15 | ```yaml 16 | - {label: "Title", name: "title", widget: "string"} 17 | ``` 18 | -------------------------------------------------------------------------------- /website/content/docs/text.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Text 3 | group: widgets 4 | --- 5 | 6 | The text widget takes a multiline text field and saves it as a string. For shorter text inputs, use the string widget. 7 | 8 | - **Name:** `text` 9 | - **UI:** HTML textarea 10 | - **Data type:** string 11 | - **Options:** 12 | - `default`: accepts a string; defaults to an empty string 13 | - **Example:** 14 | 15 | ```yaml 16 | - {label: "Description", name: "description", widget: "text"} 17 | ``` 18 | 19 | -------------------------------------------------------------------------------- /website/content/docs/update-the-cms-version.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Update the CMS Version 3 | weight: 60 4 | group: start 5 | --- 6 | 7 | ## CDN 8 | 9 | If you are using the CMS through a CDN like JsDelivr, then that depends on the version tag you are using. You can find the version tag you are using in the `/admin/index.html` file of your site. 10 | 11 | - (Recommended) If you use `^1.0.0`, the CMS will do all updates except major versions automatically. 12 | - It will upgrade to `1.0.1`, `1.1.0`, `1.1.2`. 13 | - It will not upgrade to `2.0.0` or higher. 14 | - It will not upgrade to beta versions. 15 | 16 | - If you use `~1.0.0`, the CMS will do only patch updates automatically. 17 | - It will upgrade `1.0.1`, `1.0.2`. 18 | - It will not upgrade to `1.1.0` or higher. 19 | - It will not upgrade beta versions. 20 | -------------------------------------------------------------------------------- /website/content/docs/widgets.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introduction 3 | weight: 30 4 | group: widgets 5 | --- 6 | 7 | Widgets define the data type and interface for entry fields. Saleina CMS comes with several built-in widgets. Click the widget names in the sidebar to jump to specific widget details. 8 | 9 | Widgets are specified as collection fields in the Saleina CMS `config.yml` file. Note that [YAML syntax](https://en.wikipedia.org/wiki/YAML#Basic_components) allows lists and objects to be written in block or inline style, and the code samples below include a mix of both. 10 | 11 | ## Common widget options 12 | 13 | The following options are available on all fields: 14 | 15 | - `required`: specify as `false` to make a field optional; defaults to `true` 16 | - `pattern`: add field validation by specifying a string with a [regex pattern](https://regexr.com/). 17 | 18 | - **Example:** 19 | 20 | ```yaml 21 | - label: "Title" 22 | name: "title" 23 | widget: "string" 24 | pattern: ".{12,}" 25 | ``` -------------------------------------------------------------------------------- /website/content/support/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | # Sponsor Saleina CMS Development 4 | 5 | Saleina CMS is an MIT licensed open source project and completely free to use. However, the amount of effort needed to maintain and develop new features for the project is not sustainable without proper financial backing. You can support Saleina CMS development via the following methods: 6 |
7 | 8 | 9 | ## Our Sponsors 10 | 11 |
12 | 13 |
-------------------------------------------------------------------------------- /website/layouts/_default/baseof.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ block "title" . }}{{ end }} 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 20 |
21 | 22 |
23 | {{ block "main" . }} 24 | {{ end }} 25 |
26 | 27 | 36 | 37 | -------------------------------------------------------------------------------- /website/layouts/_default/li.html: -------------------------------------------------------------------------------- 1 |
2 |

{{ .Title }}

3 |

{{ .Summary }}

4 |
5 | read more 6 |
7 |
-------------------------------------------------------------------------------- /website/layouts/_default/list.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Saleina CMS Blog{{ end }} 2 | 3 | {{ define "main" }} 4 |
5 | {{ range .Paginator.Pages }} 6 | {{ .Render "li" }} 7 | {{ end }} 8 |
9 | {{ end }} -------------------------------------------------------------------------------- /website/layouts/_default/single.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}{{ .Title }} | Saleina CMS Blog{{ end }} 2 | 3 | {{ define "main" }} 4 |

{{ .Title }}

5 | {{ .Content }} 6 | {{ end }} -------------------------------------------------------------------------------- /website/layouts/docs/single.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Saleina CMS | {{ .Title }}{{ end }} 2 | 3 | {{ define "main" }} 4 | 5 |
6 | 7 | 31 | 32 |
33 | 34 |

{{ .Title }}

35 | 36 | {{ .Content }} 37 | 38 |
39 | 40 |
41 | 42 | {{ end }} -------------------------------------------------------------------------------- /website/layouts/index.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Saleina CMS | Open Source Content Managment System | Static Content Management System{{ end }} 2 | 3 | {{ define "main" }} 4 |

Open source static content management system for your Git workflow

5 | get started 6 | 7 |
8 | 9 | 32 | 33 |
34 | {{ end }} -------------------------------------------------------------------------------- /website/layouts/section/support.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Saleina CMS | Support Us{{ end }} 2 | 3 | {{ define "main" }} 4 | {{ .Content }} 5 | {{ end }} -------------------------------------------------------------------------------- /website/static/_redirects: -------------------------------------------------------------------------------- 1 | https://saleinacms.com/* https://saleinacms.org/:splat 301! 2 | https://www.saleinacms.com/* https://saleinacms.org/:splat 301! 3 | 4 | # Optional: Redirect default Netlify subdomain to primary domain 5 | https://saleinacms.netlify.com/* https://saleinacms.org/:splat 301! -------------------------------------------------------------------------------- /website/static/admin/config.yml: -------------------------------------------------------------------------------- 1 | backend: 2 | name: gitlab 3 | repo: saleina/saleinacms 4 | client_id: 83cb793b4a6a96883c9502178b2c58151310851f9e44263f9ff19c3ad0da7357 5 | branch: master 6 | 7 | media_folder: /website/static/images/uploads/ 8 | 9 | public_folder: /images/uploads/ 10 | 11 | collections: 12 | - label: Blog 13 | name: blog 14 | folder: website/content/blog 15 | tabs: 16 | - label: Basic 17 | fields: 18 | - {label: Title, name: title, widget: string} 19 | - {label: Publish Date, name: date, widget: datetime, format: "Z"} 20 | - label: Body 21 | fields: 22 | - {label: Body, name: body, widget: markdown} 23 | 24 | - label: Docs 25 | name: docs 26 | folder: website/content/docs 27 | tabs: 28 | - label: Basic 29 | fields: 30 | - {label: Title, name: title, widget: string} 31 | - label: Group 32 | name: group 33 | widget: select 34 | options: 35 | - {label: Start, value: start} 36 | - {label: Widgets, value: widgets} 37 | - {label: Reference, value: reference} 38 | - {label: Weight, name: weight, widget: number, required: false} 39 | - label: Body 40 | fields: 41 | - {label: Body, name: body, widget: markdown} 42 | -------------------------------------------------------------------------------- /website/static/admin/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | SaleinaCMS 8 | 9 | 10 | 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /website/static/css/style.styl: -------------------------------------------------------------------------------- 1 | @import "../../../node_modules/nib" 2 | 3 | $white = #fff 4 | $black = #323d47 5 | $green = #009688 6 | $lightgreen = #aeddd9 7 | $grey = #fafbfb 8 | $darkgrey = #939393 9 | 10 | * 11 | padding 0 12 | margin 0 13 | outline none 14 | border none 15 | box-sizing border-box 16 | font-family "Open Sans" 17 | 18 | a 19 | color $green 20 | 21 | a.button, button 22 | text-decoration none 23 | text-transform uppercase 24 | color $white 25 | background $green 26 | font-weight 700 27 | display inline-block 28 | height 30px 29 | line-height 30px 30 | padding 0 20px 31 | border-radius 3px 32 | transition all .3s linear 33 | &:disabled 34 | background #78bab4 35 | &:hover 36 | box-shadow none 37 | &:hover 38 | transition all .3s linear 39 | box-shadow 0px 0px 2px 2px rgba(0,0,0,0.30) 40 | 41 | body, html 42 | height 100% 43 | 44 | body 45 | max-width 1150px 46 | width 100% 47 | margin 0 auto 48 | color $black 49 | background-repeat no-repeat 50 | background $lightgreen 51 | background linear-gradient(150deg, rgba(0,150,136,0.2497199563419118) 0%, rgba(0,150,136,0.3925770991990546) 50%, rgba(0,150,136,0.6894958667060574) 100%) fixed 52 | 53 | h1, h2, h3, h4, h5, h6 54 | margin-bottom 20px 55 | 56 | h1 57 | font-size 40px 58 | font-weight 700 59 | 60 | h2 61 | font-size 35px 62 | font-weight 600 63 | 64 | h3 65 | font-size 30px 66 | font-weight 500 67 | 68 | h4 69 | font-size 25px 70 | font-weight 400 71 | 72 | h5 73 | font-size 20px 74 | font-weight 300 75 | 76 | h6 77 | font-size 10px 78 | font-weight 300 79 | 80 | header 81 | width 100% 82 | padding 20px 0 83 | display flex 84 | flex-wrap wrap 85 | align-items center 86 | 87 | ul 88 | padding 10px 40px 89 | 90 | table 91 | width 100% 92 | border 1px solid $black 93 | margin 10px 0 94 | border-collapse collapse 95 | 96 | tr, td, th 97 | padding 10px 98 | border 1px solid $black 99 | pre 100 | background #383838 !important 101 | word-wrap break-word 102 | white-space pre-wrap 103 | padding 10px 104 | font-size 14px 105 | border-radius 3px 106 | margin 10px 0 107 | code 108 | background inherit 109 | font-weight 400 110 | 111 | p 112 | line-height 28px 113 | 114 | code 115 | font-weight 700 116 | 117 | #logo 118 | flex 1 119 | min-width 250px 120 | img 121 | width 100% 122 | padding-right 20px 123 | border-right 2px solid $darkgrey 124 | 125 | #nav 126 | display flex 127 | justify-content space-between 128 | flex 7 129 | height 100% 130 | min-width 300px 131 | padding 10px 132 | 133 | #sub-nav 134 | padding 0 135 | list-style none 136 | li 137 | display inline-block 138 | margin-left 10px 139 | a 140 | color $darkgrey 141 | text-decoration none 142 | text-transform uppercase 143 | font-weight 600 144 | font-size 0.87em 145 | transition all .3s linear 146 | &:hover 147 | transition all .3s linear 148 | color $black 149 | 150 | #main-content 151 | display flex 152 | flex-wrap wrap 153 | 154 | #why 155 | list-style none 156 | h5 157 | font-weight 700 158 | li 159 | margin-top 20px 160 | p 161 | color darken($darkgrey, 30%) 162 | 163 | #main-docs 164 | display flex 165 | flex-wrap wrap 166 | #aside 167 | flex 2 168 | min-width 300px 169 | padding 0 10px 170 | #docs-content 171 | flex 4 172 | padding 0 10px 173 | 174 | #aside 175 | padding 20px 176 | ul 177 | list-style none 178 | padding 0 10px 179 | h5 180 | margin-bottom 0 !important 181 | font-weight 700 182 | a 183 | color $darkgrey 184 | text-decoration none 185 | &:hover 186 | color $green 187 | text-decoration underline 188 | 189 | .blog-post 190 | margin-bottom 20px 191 | h2 192 | color $green 193 | .actions 194 | text-align right 195 | padding 10px 0 196 | .button 197 | font-weight 400 198 | 199 | #footer 200 | padding 30px 10px 201 | display flex 202 | justify-content space-between 203 | color $darkgrey 204 | flex-wrap wrap 205 | a.button 206 | font-weight 400 207 | font-size 14px 208 | color $white 209 | margin-right 5px 210 | transition all .3s linear 211 | &:hover 212 | transition all .3s linear 213 | color $white 214 | a 215 | color $darkgrey 216 | text-decoration none 217 | font-size 14px 218 | transition all .3s linear 219 | &:hover 220 | color $black 221 | transition all .3s linear 222 | 223 | #social 224 | min-width 300px -------------------------------------------------------------------------------- /website/static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saleina/SaleinaCMS/e988f0f1530fb1d73f1a1770b3c91bbafe9c302b/website/static/images/logo.png --------------------------------------------------------------------------------