├── icon.png ├── icon-s.png ├── CONTRIBUTING ├── components ├── index.js ├── FileUpload.js ├── Landing.js ├── Preview.js ├── ProseMirror.js └── Repo.js ├── .gitignore ├── helpers ├── base64.js ├── render-with-frontmatter.js ├── storage.js └── github.js ├── constants.js ├── .travis.yml ├── LICENSE ├── app.js ├── log.js ├── index.html ├── package.json ├── Main.js ├── service-worker.js ├── README.md ├── main.scss ├── prosemirror.scss ├── preferences.js └── state.js /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fiatjaf/coisas/HEAD/icon.png -------------------------------------------------------------------------------- /icon-s.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fiatjaf/coisas/HEAD/icon-s.png -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | Everything is a mess! Send your PR and we'll discuss it, merge it, deploy it then see if it works and finally fix it! 2 | -------------------------------------------------------------------------------- /components/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'index': require('./Landing'), 3 | 'repo': require('./Repo'), 4 | 'div': 'div' 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.swo 3 | *.swp 4 | npm-debug.log 5 | browserify-cache.json 6 | bundle.* 7 | style.css 8 | package-lock.json 9 | -------------------------------------------------------------------------------- /helpers/base64.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | encode: toBase64, 3 | decode: fromBase64 4 | } 5 | 6 | function toBase64 (str) { 7 | return window.btoa(unescape(encodeURIComponent(str))) 8 | } 9 | 10 | function fromBase64 (str) { 11 | return decodeURIComponent(escape(window.atob(str))) 12 | } 13 | -------------------------------------------------------------------------------- /constants.js: -------------------------------------------------------------------------------- 1 | // modes 2 | const ADD = '' 3 | const REPLACE = '' 4 | const UPLOAD = '' 5 | const EDIT = '' 6 | const DIRECTORY = '' 7 | 8 | module.exports.modes = {ADD, REPLACE, UPLOAD, EDIT, DIRECTORY} 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "7.7.3" 4 | sudo: false 5 | dist: trusty 6 | install: 7 | - npm install 8 | script: 9 | - npm run build-prod 10 | before_deploy: 11 | - echo coisas.fiatjaf.com > CNAME 12 | - rm -rf .gitignore components/ node_modules/ LICENSE README.md Main.js app.js helpers/ log.js state.js preferences.js Caddyfile .travis.yml package* 13 | deploy: 14 | provider: pages 15 | skip_cleanup: true 16 | github_token: $GITHUB_TOKEN # Set in travis-ci.org dashboard 17 | on: 18 | branch: master 19 | target_branch: gh-pages 20 | -------------------------------------------------------------------------------- /helpers/render-with-frontmatter.js: -------------------------------------------------------------------------------- 1 | module.exports = function renderWithFrontmatter (content, metadata, repoSlug) { 2 | let rawgithuburl = RegExp( 3 | '\\]\\(https:\\/\\/raw.githubusercontent.com\\/' + repoSlug + '\\/master', 'g') 4 | 5 | var full = '' 6 | if (metadata && Object.keys(metadata).length) { 7 | let meta = Object.keys(metadata).map(k => 8 | `${k}: ${JSON.stringify(metadata[k])}` 9 | ).join('\n') 10 | 11 | full += '---\n' + meta + '\n---\n' 12 | } 13 | 14 | full += content.replace(rawgithuburl, '](') 15 | return full 16 | } 17 | -------------------------------------------------------------------------------- /components/FileUpload.js: -------------------------------------------------------------------------------- 1 | const h = require('react-hyperscript') 2 | const Dropzone = require('react-dropzone') 3 | 4 | module.exports = function FileUpload ({ 5 | onFile = () => {}, 6 | onBase64 = () => {}} 7 | ) { 8 | return h(Dropzone, { 9 | multiple: false, 10 | disablePreview: true, 11 | className: 'dropzone', 12 | onDrop: files => { 13 | let file = files[0] 14 | onFile(file) 15 | var reader = new window.FileReader() 16 | reader.onload = event => { 17 | let binary = event.target.result 18 | let b64 = window.btoa(binary) 19 | onBase64(b64) 20 | } 21 | reader.readAsBinaryString(file) 22 | } 23 | }, [ 24 | h('.placeholder', 'Click or drop files here to upload') 25 | ]) 26 | } 27 | -------------------------------------------------------------------------------- /components/Landing.js: -------------------------------------------------------------------------------- 1 | const h = require('react-hyperscript') 2 | const page = require('page') 3 | const storage = require('../helpers/storage') 4 | 5 | module.exports = function Landing (ctx) { 6 | 7 | return h('#Landing', [ 8 | h('center', [ 9 | h('p', 'Recently Viewed Repositories: '), 10 | h('ul', [storage.getRepoHistory()]), 11 | h('form', { 12 | onSubmit: e => { 13 | e.preventDefault() 14 | let v = e.target.repo.value 15 | let slug = /(github.com\/)?([^\/]+)\/([^\/]+)(\/.*)?/.exec(v).slice(2, 4).join('/') 16 | page('#!/' + slug + '/') 17 | } 18 | }, [ 19 | h('p', 'Type your GitHub repository name'), 20 | h('input.input.is-large', {name: 'repo', placeholder: 'fiatjaf/coisas'}), 21 | h('button.button.is-large.is-dark', 'Go') 22 | ]) 23 | ]) 24 | ]) 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 fiatjaf 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /components/Preview.js: -------------------------------------------------------------------------------- 1 | const h = require('react-hyperscript') 2 | const {append: renderMedia} = require('render-media') 3 | const ReadableBlobStream = require('readable-blob-stream') 4 | const based = require('based-blob') 5 | 6 | module.exports = function Preview ({children, name, string, base64, blobURL, blob}) { 7 | if (base64 && !blob) { 8 | try { 9 | blob = based.toBlob(base64) 10 | } catch (e) { 11 | console.warn(e) 12 | } 13 | } 14 | 15 | var readableStream 16 | if (blob) { 17 | readableStream = new ReadableBlobStream(blob) 18 | } 19 | 20 | return h('.preview', { 21 | ref: el => { 22 | if (el) { 23 | var fp = el.getElementsByClassName('file')[0] 24 | if (!fp) { 25 | fp = document.createElement('div') 26 | fp.className = 'file' 27 | el.insertBefore(fp, el.childNodes[0]) 28 | } 29 | 30 | fp.innerHTML = '' 31 | renderMedia({ 32 | name, 33 | createReadStream: () => readableStream 34 | }, fp, console.log.bind(console, 'render-media')) 35 | } 36 | } 37 | }, children) 38 | } 39 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | // buble needs this: 2 | window.xtend = require('xtend') 3 | 4 | // export all these libraries so remote coisas.js can use them 5 | // it costs nothing to do so. 6 | window.load = (url, cb) => { 7 | if (cb) { 8 | return require('fetch-js')(url, cb) 9 | } 10 | 11 | return new Promise((resolve, reject) => 12 | require('fetch-js')(url, err => { 13 | if (err) return reject(err) 14 | resolve() 15 | }) 16 | ) 17 | } 18 | window.React = require('react') 19 | window.ReactDOM = require('react-dom') 20 | window.h = require('react-hyperscript') 21 | window.matter = require('gray-matter') 22 | // ~ 23 | 24 | require('./preferences') 25 | 26 | const React = require('react') 27 | const render = require('react-dom').render 28 | 29 | const Main = require('./Main') 30 | 31 | render( 32 | React.createElement(Main), 33 | document.getElementById('root') 34 | ) 35 | 36 | if ('serviceWorker' in navigator) { 37 | navigator.serviceWorker.register('/service-worker.js', {scope: '/'}) 38 | .then(reg => console.log('service worker registered.', reg.scope)) 39 | .catch(e => console.log('failed to register service worker.', e)) 40 | } 41 | -------------------------------------------------------------------------------- /log.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | info () { 3 | notie.alert({ 4 | text: Array.prototype.join.call(arguments, ' '), 5 | type: 'info', 6 | time: 3 7 | }) 8 | console.log.apply(console, arguments) 9 | }, 10 | 11 | error (e) { 12 | if (e.stack) { 13 | console.error(e.stack) 14 | notie.alert({ 15 | text: 'Something wrong has occurred, see the console for the complete error.', 16 | type: 'error', 17 | time: 3 18 | }) 19 | return 20 | } 21 | 22 | notie.alert({ 23 | text: Array.prototype.join.call(arguments, ' '), 24 | type: 'error', 25 | time: 5 26 | }) 27 | console.error.apply(console, arguments) 28 | }, 29 | 30 | success () { 31 | notie.alert({ 32 | text: Array.prototype.join.call(arguments, ' '), 33 | type: 'success', 34 | time: 3 35 | }) 36 | }, 37 | 38 | confirm (text, confirmed, cancelled) { 39 | notie.confirm({text}, confirmed, cancelled) 40 | } 41 | } 42 | 43 | const notie = { 44 | alert: function (params) { 45 | if (window.notie) { 46 | window.notie.alert(params) 47 | } else { 48 | setTimeout(() => notie.alert(params), 1000) 49 | } 50 | }, 51 | confirm: function () { 52 | if (window.notie) { 53 | window.notie.confirm.apply(window.notie, arguments) 54 | } else { 55 | setTimeout(() => notie.confirm.apply(notie, arguments), 1000) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /helpers/storage.js: -------------------------------------------------------------------------------- 1 | const h = require('react-hyperscript') 2 | const page = require('page') 3 | 4 | function addToStorageString(oldVal, newVal){ 5 | if (newVal === ''){ 6 | return oldVal; 7 | } 8 | let arr = oldVal.split(' '); 9 | 10 | while (arr.indexOf(newVal) !== -1){ 11 | var index = arr.indexOf(newVal); 12 | arr.splice(index, 1); 13 | } 14 | arr.push(newVal); 15 | while (arr.length > 5){ 16 | arr.splice(0, 1); 17 | } 18 | return arr.join(' '); 19 | } 20 | 21 | Storage.prototype.setObject = function(key, value) { 22 | let temp = localStorage.getObject(key); 23 | if (temp){ 24 | value = addToStorageString(temp, value); 25 | } 26 | this.setItem(key, JSON.stringify(value)); 27 | }; 28 | 29 | Storage.prototype.getObject = function(key) { 30 | var value = this.getItem(key); 31 | return value && JSON.parse(value); 32 | }; 33 | 34 | function onRepoClick(repoName) { 35 | const slug = /(github.com\/)?([^\/]+)\/([^\/]+)(\/.*)?/.exec(repoName).slice(2, 4).join('/'); 36 | page('#!/' + slug + '/'); 37 | } 38 | 39 | module.exports = { 40 | storeRepo: function storeRepo(repoName) { 41 | localStorage.setObject('repoHistory', repoName); 42 | }, 43 | getRepoHistory: function getRepoHistory() { 44 | const history = localStorage.getObject('repoHistory'); 45 | if (history){ 46 | const historyList = history.split(' '); 47 | const historyListItems = historyList.map( (repo) => { 48 | return h('li.repoListItem', {key: repo, onClick: () => onRepoClick(repo)}, repo); 49 | }); 50 | return historyListItems.reverse(); 51 | } 52 | return h('li', 'No recently visited repositories.'); 53 | } 54 | 55 | }; -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Coisas CMS 12 |
13 | 14 | 15 | 16 | 17 | 18 | 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "css": "node-sass main.scss > style.css", 4 | "watch": "find -name \"*.js\" ! -name \"bundle.js\" ! -path \"*node_modules*\" | entr browserifyinc -vd app.js -o bundle.js", 5 | "build": "browserify app.js -o bundle.js && npm run css", 6 | "build-prod": "env NODE_ENV=production npm run build" 7 | }, 8 | "browserify": { 9 | "transform": [ 10 | [ 11 | "bubleify", 12 | { 13 | "transforms": { 14 | "dangerousTaggedTemplateString": true 15 | }, 16 | "objectAssign": "xtend", 17 | "sourceMap": true 18 | } 19 | ] 20 | ] 21 | }, 22 | "dependencies": { 23 | "based-blob": "^1.0.1", 24 | "debounce": "^1.0.2", 25 | "draft-js": "^0.10.1", 26 | "fetch-js": "^1.0.3", 27 | "fwitch": "^1.0.1", 28 | "gray-matter": "^3.0.2", 29 | "mobx": "^4.1.1", 30 | "mobx-react": "^5.0.0", 31 | "page": "^1.7.1", 32 | "prosemirror-example-setup": "^0.22.0", 33 | "prosemirror-markdown": "^0.22.0", 34 | "prosemirror-menu": "^0.22.0", 35 | "prosemirror-state": "^0.22.0", 36 | "prosemirror-view": "^0.22.0", 37 | "qs": "^6.5.0", 38 | "react": "^15.6.1", 39 | "react-codemirror": "github:skidding/react-codemirror#bcbb50b", 40 | "react-dom": "^15.6.1", 41 | "react-dropzone": "^3.13.3", 42 | "react-hyperscript": "^3.0.0", 43 | "react-json": "github:fiatjaf/react-json#980ae69ab4cd22bacfb40022f7ccd33019bc3c4e", 44 | "react-treeview": "^0.4.7", 45 | "readable-blob-stream": "^1.1.0", 46 | "render-media": "^2.10.0" 47 | }, 48 | "devDependencies": { 49 | "browserify": "^14.3.0", 50 | "browserify-incremental": "^3.1.1", 51 | "bubleify": "^1.1.0", 52 | "bulma": "^0.5.0", 53 | "node-sass": "^4.5.3" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /components/ProseMirror.js: -------------------------------------------------------------------------------- 1 | const React = require('react') 2 | const {EditorView} = require('prosemirror-view') 3 | const {EditorState} = require('prosemirror-state') 4 | const {schema, defaultMarkdownParser, defaultMarkdownSerializer} = require('prosemirror-markdown') 5 | const {exampleSetup} = require('prosemirror-example-setup') 6 | const h = require('react-hyperscript') 7 | const debounce = require('debounce') 8 | 9 | module.exports = class ProseMirror extends React.Component { 10 | componentDidMount () { 11 | this.start(this.props.defaultValue) 12 | this.dchanged = debounce(this.changed, 600) 13 | } 14 | 15 | start (value = '') { 16 | this.value = value 17 | 18 | this.state = EditorState.create({ 19 | doc: defaultMarkdownParser.parse(value), 20 | plugins: exampleSetup({schema}) 21 | }) 22 | this.view = new EditorView(this.node, { 23 | state: this.state, 24 | dispatchTransaction: (txn) => { 25 | let nextState = this.view.state.apply(txn) 26 | this.view.updateState(nextState) 27 | this.dchanged(txn) 28 | } 29 | }) 30 | } 31 | 32 | changed (txn) { 33 | if (txn.docChanged) { 34 | let content = defaultMarkdownSerializer.serialize(this.view.state.doc) 35 | this.value = content 36 | this.props.onChange(content) 37 | } 38 | } 39 | 40 | componentWillReceiveProps (nextProps) { 41 | if (this.value !== nextProps.defaultValue) { 42 | this.componentWillUnmount() 43 | if (nextProps.defaultValue) { 44 | this.start(nextProps.defaultValue) 45 | } else { 46 | this.value = nextProps.defaultValue 47 | } 48 | } 49 | } 50 | 51 | componentWillUnmount () { 52 | if (this.view) this.view.destroy() 53 | } 54 | 55 | render () { 56 | return h('div', { 57 | ref: el => { 58 | this.node = el 59 | } 60 | }) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /helpers/github.js: -------------------------------------------------------------------------------- 1 | const fetch = window.fetch 2 | const qs = require('qs') 3 | 4 | module.exports = gh 5 | 6 | function gh (method, path, data = {}) { 7 | var waitToken = window.coisas.authorizationLoad(method) 8 | 9 | let headers = { 10 | 'Accept': 'application/vnd.github.v3+json', 11 | 'User-Agent': 'github.com/fiatjaf', 12 | 'Content-Type': 'application/json', 13 | ...data.headers 14 | } 15 | delete data.headers 16 | 17 | var body 18 | if (method === 'get' || method === 'head') { 19 | path += '?' + qs.stringify(data) 20 | } else { 21 | body = JSON.stringify(data) 22 | } 23 | 24 | if (method === 'put' || method === 'delete' || method === 'post' || method === 'patch') { 25 | window.tc && window.tc(7) 26 | } 27 | 28 | return waitToken 29 | .then(token => { 30 | headers['Authorization'] = token 31 | }) 32 | .catch(() => { 33 | if (method === 'put' || method === 'delete' || method === 'post' || method === 'patch') { 34 | throw new Error("Can't call the GitHub API without a valid token.") 35 | } 36 | }) 37 | .then(() => 38 | fetch(`https://api.github.com/${path}`, {method, headers, body}) 39 | ) 40 | .then(r => { 41 | if (r.status >= 300) throw r 42 | return headers.Accept.match(/json/) ? r.json() : r.text() 43 | }) 44 | } 45 | 46 | gh.get = gh.bind(gh, 'get') 47 | gh.post = gh.bind(gh, 'post') 48 | gh.put = gh.bind(gh, 'put') 49 | gh.head = gh.bind(gh, 'head') 50 | gh.patch = gh.bind(gh, 'patch') 51 | gh.delete = gh.bind(gh, 'delete') 52 | 53 | const {ADD, REPLACE, UPLOAD, EDIT} = require('../constants').modes 54 | 55 | module.exports.saveFile = ({mode, path, sha, content, repoSlug}) => { 56 | var message 57 | switch (mode) { 58 | case EDIT: 59 | message = `updated ${path}.` 60 | break 61 | case ADD: 62 | message = `created ${path}.` 63 | break 64 | case REPLACE: 65 | message = `replaced ${path} with upload.` 66 | break 67 | case UPLOAD: 68 | message = `uploaded ${path}` 69 | break 70 | } 71 | 72 | let body = { message, sha, content } 73 | 74 | return gh.put(`repos/${repoSlug}/contents/${path}`, body) 75 | } 76 | -------------------------------------------------------------------------------- /Main.js: -------------------------------------------------------------------------------- 1 | const h = require('react-hyperscript') 2 | const {observer} = require('mobx-react') 3 | 4 | const {loadUser} = require('./state') 5 | const state = require('./state') 6 | const log = require('./log') 7 | 8 | module.exports = observer(() => { 9 | return ( 10 | h('div', [ 11 | h('nav.nav', [ 12 | h('.nav-left', [ 13 | h('a.nav-item', {href: '#!/'}, [ 14 | h('img', {src: 'https://raw.githubusercontent.com/fiatjaf/coisas/master/icon.png'}) 15 | ]), 16 | h('a.nav-item', {href: '#!/'}, 'coisas') 17 | ]), 18 | h('.nav-center', [ 19 | window.coisas.liveSiteURL 20 | ? h('a.nav-item', { 21 | href: window.coisas.liveSiteURL, 22 | title: window.coisas.liveSiteURL, 23 | target: '_blank' 24 | }, [ 25 | h('span.icon', [ h('i.fa.fa-external-link-square') ]), 26 | 'Live site' 27 | ]) 28 | : null, 29 | state.slug.get() 30 | ? h('a.nav-item', { 31 | href: `https://github.com/${state.slug.get()}`, 32 | title: state.slug.get(), 33 | target: '_blank' 34 | }, [ 35 | h('span.icon', [ h('i.fa.fa-github-square') ]), 36 | 'Browse repository' 37 | ]) 38 | : null, 39 | state.loggedUser.get() 40 | ? h('.nav-item', [ 41 | state.loggedUser.get(), 42 | h('span', {style: {marginRight: '5px'}}, ', '), 43 | h('a', { 44 | onClick: () => 45 | window.coisas.authorizationRemove() 46 | .then(loadUser) 47 | }, 'logout') 48 | ]) 49 | : h('a.nav-item', { 50 | onClick: () => { 51 | window.coisas.authorizationInit() 52 | .then(() => { 53 | log.success('Got GitHub token and stored it locally.') 54 | loadUser() 55 | }) 56 | .catch(log.error) 57 | } 58 | }, 'authorize on GitHub') 59 | ]) 60 | ]), 61 | h(components[state.route.get().componentName]) 62 | ]) 63 | ) 64 | }) 65 | 66 | const components = require('./components') 67 | -------------------------------------------------------------------------------- /service-worker.js: -------------------------------------------------------------------------------- 1 | /* global caches, self, fetch */ 2 | 3 | const always = [ 4 | 'https://maxcdn.bootstrapcdn.com/font-awesome/4.6.3/css/font-awesome.min.css', 5 | 'https://cdnjs.cloudflare.com/ajax/libs/notie/4.3.1/notie.min.css', 6 | 'https://cdnjs.cloudflare.com/ajax/libs/notie/4.3.0/notie.min.js', 7 | 'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.27.4/codemirror.min.css', 8 | 'https://cdn.rawgit.com/chenglou/react-treeview/6e9eacf4/react-treeview.css', 9 | 'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.28.0/mode/css/css.min.js', 10 | 'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.28.0/mode/htmlmixed/htmlmixed.min.js', 11 | 'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.28.0/mode/javascript/javascript.js' 12 | ] 13 | 14 | const currentCache = 'v1' 15 | 16 | this.addEventListener('install', event => { 17 | event.waitUntil( 18 | caches.open(currentCache).then(cache => { 19 | return cache.addAll(always) 20 | }) 21 | ) 22 | }) 23 | 24 | this.addEventListener('activate', event => { 25 | event.waitUntil( 26 | caches.keys().then(keyList => 27 | Promise.all(keyList.map(key => { 28 | if (key !== currentCache) { 29 | return caches.delete(key) 30 | } 31 | })) 32 | ) 33 | ) 34 | }) 35 | 36 | var currentRepo = '' 37 | 38 | self.addEventListener('message', event => { 39 | currentRepo = event.data.currentRepo 40 | }) 41 | 42 | self.addEventListener('fetch', event => { 43 | if (event.request.method !== 'GET') return 44 | if (event.request.url.slice(0, 4) !== 'http') return 45 | 46 | // the predefined urls we'll always serve them from the cache 47 | if (always.indexOf(event.request.url) !== -1) { 48 | event.respondWith(caches.match(event.request)) 49 | return 50 | } 51 | 52 | // for the image urls we'll try the current github repository 53 | let networkURL = event.request.url.split('#')[0] 54 | if (currentRepo && 55 | networkURL.match(location.host) && 56 | networkURL.match(/(png|jpe?g|gif|svg)$/)) { 57 | let path = networkURL.split('/').slice(3).join('/') 58 | 59 | event.respondWith( 60 | fetch( 61 | `https://raw.githubusercontent.com/${currentRepo}/master/${path}` 62 | ) 63 | ) 64 | return 65 | } 66 | 67 | // try to fetch from the network 68 | event.respondWith( 69 | fetch(event.request) 70 | .then(response => { 71 | // save a clone of our response in the cache 72 | let cacheCopy = response.clone() 73 | caches.open(currentCache).then(cache => cache.put(event.request, cacheCopy)) 74 | return response 75 | }) 76 | // if it fails we'll serve from the cache 77 | .catch(caches.match(event.request)) 78 | ) 79 | }) 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](icon-s.png) coisas 2 | ==================== 3 | 4 | **coisas** is a headless CMS specifically designed to let you edit files hosted in a GitHub repository. It is similar to [Netlify CMS](https://github.com/netlify/netlify-cms) and [Prose](http://prose.io/). Unlike existing alternatives, **coisas** doesn't try to be a multipurpose CMS. It still lets you edit, create, upload, and browse files, but doesn't try to look like a fancy CMS (custom schema, objects and all that mess). It also isn't tailored to Jekyll websites, which means that it won't insert Jekyll specific code or expect your repository to have a Jekyll-specific file structure. 5 | 6 | Other features that **coisas** includes are: 7 | 8 | * file tree view; 9 | * simple metadata editor and automatic saving of Markdown and HTML files with YAML front-matter; 10 | * behavior customizations that can be configured from your repository, while still accessing **coisas** from its own URL; 11 | * easy embedding in your own site, so you'll never have to touch **coisas** own URL; 12 | * image gallery with all the images from your repository, so you can drag and drop them inside the editor; 13 | * simple visualization of many file formats (only text files are editable, however). 14 | 15 | ## usage 16 | 17 | To use **coisas**, go to https://coisas.fiatjaf.com/ or embed it in your site, for example, in an `/admin/` section (more detailed instructions on how to do this may come - for the meantime please copy the hosted version file structure). 18 | 19 | ## demo 20 | 21 | There is a demo site at https://geraldoquagliato.github.io/, which you can browse and edit (no login necessary) by visiting https://coisas.fiatjaf.com/#!/geraldoquagliato/geraldoquagliato.github.io/. Please be decent. 22 | 23 | ## customization 24 | 25 | To customize the app behavior specifically for your repository, create a file named `coisas.js` and put it at the root of the repository. That file may contain anything and will be loaded and executed dynamically by **coisas** as part of its initialization process. 26 | 27 | From that file you must modify the global object `window.coisas`, whose defaults are specified in [preferences.js](preferences.js) (along with comments to explain each property). If you need more customization options I'm happy to include them, please open an issue. 28 | 29 | #### styles 30 | 31 | You can customize many of the original styles of **coisas** UI (which, I admit, are not pretty). You can do it by simply modifying the [CSS variables](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_variables) made available at the top of [main.scss](main.scss) in your `coisas.js` file along with their defaults. 32 | 33 | Basically you just run `document.body.style.setProperty("--variable-name", "value")`. The names should be somewhat self-descriptive, but if they aren't please solve that by doing manual experimentation in the browser console. 34 | 35 | #### previews 36 | 37 | Through the customization file, you may define a couple of functions that will enable previews in the edit session of **coisas** (a couple of buttons will be shown allowing the editor to switch between the _edit_ view and the _preview_ view). See [preferences.js](preferences.js) for more information about how to do that. 38 | 39 | ## development 40 | 41 | To run **coisas** locally, you can `git clone` the repo, then `cd` to it and `npm install`, then `npm run build`. If you want to rebuild automatically every time you change a file, you'll need [entr](http://entrproject.org/), so you can `npm run watch`. 42 | 43 | Besides all that, a static server is needed. There are thousands out there for you to choose. My current preference is [Caddy](https://caddyserver.com/), because it will run your site on HTTPS automatically if you have a canonical hostname (just modify [Caddyfile](Caddyfile) with yours). Running **coisas** on HTTPS is required for the [service-worker.js](service-worker.js) to be installed, but that is not necessary (although without it the editor image previews may fail). 44 | 45 | ## meta 46 | 47 | ##### Source tree for this repository 48 | 49 | (The majority of action happens at [components/Repo.js](components/Repo.js) and [state.js](state.js), although Prosemirror takes a lot of space in the tree due to its hypermodularization) 50 | 51 | ![](http://node-dependencies-view.glitch.me/fiatjaf/coisas) 52 | 53 | ##### Visit analytics for this repository 54 | 55 | [![](https://ght.trackingco.de/fiatjaf/coisas)](https://ght.trackingco.de/) 56 | -------------------------------------------------------------------------------- /main.scss: -------------------------------------------------------------------------------- 1 | @import "node_modules/bulma/bulma.sass"; 2 | 3 | :root { 4 | --main-background: #bcf7d3; 5 | --main-text: #4a4a4a; 6 | 7 | --nav-background: white; 8 | --nav-text: #7a7a7a; 9 | --nav-link: #363636; 10 | 11 | --tree-folder-text: #4a4a4a; 12 | --tree-file-text: #4a4a4a; 13 | --tree-file-active-background: #00d1b2; 14 | --tree-file-active-text: white; 15 | --tree-file-hover-background: #00d1b2; 16 | --tree-file-hover-text: white; 17 | 18 | --save-button-background: #00d1b2; 19 | --save-button-text: white; 20 | --upload-button-background: #363636; 21 | --upload-button-text: white; 22 | --delete-button-background: #ffdd57; 23 | --delete-button-text: #363636; 24 | --delete-cancel-button-background: white; 25 | --delete-cancel-button-text: #363636; 26 | --delete-confirm-button-background: #ff2b56; 27 | --delete-confirm-button-text: white; 28 | 29 | --editor-background: white; 30 | --editor-text: #4a4a4a; 31 | 32 | --file-area: #999; 33 | } 34 | 35 | @import "prosemirror.scss"; 36 | 37 | html, body { min-height: 100vh; } 38 | 39 | body { 40 | background-color: var(--main-background); 41 | color: var(--main-text); 42 | } 43 | 44 | nav.nav { 45 | background-color: var(--nav-background); 46 | color: var(--nav-text); 47 | 48 | a { 49 | color: var(--nav-link); 50 | 51 | &:hover { 52 | filter: brightness(0.6); 53 | } 54 | } 55 | } 56 | 57 | main { 58 | padding-top: 30px; 59 | } 60 | 61 | #Landing { 62 | margin: 200px auto; 63 | 64 | input, 65 | button { 66 | margin: 12px; 67 | display: block; 68 | width: 600px; 69 | text-align: center; 70 | } 71 | 72 | ul { 73 | li.repoListItem { 74 | display: inline; 75 | padding-right: 15px; 76 | &:hover { 77 | cursor: pointer; 78 | text-decoration: underline; 79 | color: black; 80 | } 81 | } 82 | 83 | padding-bottom: 20px; 84 | } 85 | } 86 | 87 | #Images { 88 | margin-top: 12px; 89 | position: sticky; 90 | top: 10px; 91 | } 92 | 93 | #Save { 94 | margin: 10px; 95 | position: sticky; 96 | top: 10px; 97 | 98 | button { 99 | width: 100%; 100 | height: auto; 101 | min-height: 123px; 102 | white-space: pre-wrap; 103 | 104 | background-color: var(--save-button-background); 105 | color: var(--save-button-text); 106 | } 107 | } 108 | 109 | button.upload { 110 | background-color: var(--upload-button-background); 111 | color: var(--upload-button-text); 112 | } 113 | 114 | #Delete button { 115 | background-color: var(--delete-button-background); 116 | color: var(--delete-button-text); 117 | 118 | .delete-cancel { 119 | background-color: var(--delete-cancel-button-background); 120 | color: var(--delete-cancel-button-text); 121 | } 122 | 123 | .delete-confirm { 124 | background-color: var(--delete-confirm-button-background); 125 | color: var(--delete-confirm-button-text); 126 | } 127 | } 128 | 129 | button.button { 130 | &:hover { 131 | filter: brightness(0.9); 132 | } 133 | 134 | &:focus, &:active { 135 | filter: brightness(0.8); 136 | } 137 | } 138 | 139 | 140 | #Page { 141 | margin-top: 20px; 142 | 143 | &.fullscreen { 144 | position: absolute; 145 | z-index: 44; 146 | background: #a3d4ff; 147 | top: 0; 148 | left: 0; 149 | right: 0; 150 | width: 100%; 151 | min-height: 100vh; 152 | height: auto; 153 | overflow: auto; 154 | margin: 0; 155 | padding: 30px; 156 | } 157 | } 158 | 159 | .preview { 160 | display: inline-block; 161 | margin: 7px; 162 | border: dotted 6px var(--file-area); 163 | 164 | & > * { width: 100%; } 165 | .file *:not(img) { min-height: 500px; } 166 | } 167 | 168 | .dropzone { 169 | margin: 7px; 170 | border: dotted 6px var(--file-area) !important; 171 | 172 | & > .placeholder { 173 | margin: 69px 20px; 174 | color: var(--file-area); 175 | text-align: center; 176 | } 177 | } 178 | 179 | .CodeMirror { 180 | border: 1px solid #eee; 181 | height: auto !important; 182 | 183 | pre { 184 | padding: 0 4px; 185 | overflow-x: initial; 186 | } 187 | } 188 | 189 | .CodeMirror, .ProseMirror { 190 | background-color: var(--editor-background); 191 | color: var(--editor-text); 192 | } 193 | 194 | .title { color: inherit; } 195 | .subtitle { color: inherit; } 196 | .content { color: inherit; } 197 | .table { color: inherit; background-color: inherit; } 198 | .tag { margin: 0 3px; } 199 | .card { margin: 12px 12px 12px 0; } 200 | .tabs { height: 3em; margin-top: -1em; } 201 | 202 | .notie-container { 203 | top: auto !important; 204 | bottom: 0; 205 | } 206 | 207 | .content pre { 208 | margin-bottom: 0 !important; 209 | } 210 | 211 | .jsonEditor .jsonName label { padding-left: 12px; } 212 | .jsonEditor .jsonName, 213 | .jsonEditor .jsonValue { 214 | background: white; 215 | padding: 5px; 216 | } 217 | 218 | .tree-view { 219 | overflow: hidden; 220 | 221 | .tree-view_item { 222 | color: var(--tree-folder-text); 223 | } 224 | 225 | a { 226 | color: var(--tree-file-text); 227 | 228 | &:hover { 229 | background-color: var(--tree-file-hover-background); 230 | color: var(--tree-file-hover-text); 231 | } 232 | 233 | &.is-active { 234 | background-color: var(--tree-file-active-background); 235 | color: var(--tree-file-active-text); 236 | } 237 | } 238 | } 239 | 240 | .menu-list a { padding: 0 .75em; } 241 | 242 | .button-list { 243 | color: #646363 !important; 244 | border-color: #565656 !important; 245 | background-color: transparent !important; 246 | &:hover { 247 | color: #bcf7d3 !important; 248 | background-color: #646363 !important; 249 | } 250 | } -------------------------------------------------------------------------------- /prosemirror.scss: -------------------------------------------------------------------------------- 1 | .ProseMirror-menubar-wrapper { 2 | margin-top: 20px; 3 | } 4 | 5 | .ProseMirror { 6 | position: relative; 7 | padding: 1px 9px; 8 | background: white; 9 | } 10 | 11 | .ProseMirror { 12 | word-wrap: break-word; 13 | white-space: pre-wrap; 14 | } 15 | 16 | .ProseMirror ul, .ProseMirror ol { 17 | cursor: default; 18 | } 19 | 20 | .ProseMirror pre { 21 | white-space: pre-wrap; 22 | } 23 | 24 | .ProseMirror li { 25 | position: relative; 26 | pointer-events: none; /* Don't do weird stuff with marker clicks */ 27 | } 28 | .ProseMirror li > * { 29 | pointer-events: auto; 30 | } 31 | 32 | .ProseMirror-hideselection *::selection { background: transparent; } 33 | .ProseMirror-hideselection *::-moz-selection { background: transparent; } 34 | 35 | .ProseMirror-selectednode { 36 | outline: 2px solid #8cf; 37 | } 38 | 39 | /* Make sure li selections wrap around markers */ 40 | 41 | li.ProseMirror-selectednode { 42 | outline: none; 43 | } 44 | 45 | li.ProseMirror-selectednode:after { 46 | content: ""; 47 | position: absolute; 48 | left: -32px; 49 | right: -2px; top: -2px; bottom: -2px; 50 | border: 2px solid #8cf; 51 | pointer-events: none; 52 | } 53 | .ProseMirror-textblock-dropdown { 54 | min-width: 3em; 55 | } 56 | 57 | .ProseMirror-menu { 58 | margin: 0 -4px; 59 | line-height: 1; 60 | } 61 | 62 | .ProseMirror-tooltip .ProseMirror-menu { 63 | width: -webkit-fit-content; 64 | width: fit-content; 65 | white-space: pre; 66 | } 67 | 68 | .ProseMirror-menuitem { 69 | margin-right: 3px; 70 | display: inline-block; 71 | } 72 | 73 | .ProseMirror-menuseparator { 74 | border-right: 1px solid #ddd; 75 | margin-right: 3px; 76 | } 77 | 78 | .ProseMirror-menu-dropdown, .ProseMirror-menu-dropdown-menu { 79 | font-size: 90%; 80 | white-space: nowrap; 81 | } 82 | 83 | .ProseMirror-menu-dropdown { 84 | vertical-align: 1px; 85 | cursor: pointer; 86 | position: relative; 87 | padding-right: 15px; 88 | } 89 | 90 | .ProseMirror-menu-dropdown-wrap { 91 | padding: 1px 0 1px 4px; 92 | display: inline-block; 93 | position: relative; 94 | } 95 | 96 | .ProseMirror-menu-dropdown:after { 97 | content: ""; 98 | border-left: 4px solid transparent; 99 | border-right: 4px solid transparent; 100 | border-top: 4px solid currentColor; 101 | opacity: .6; 102 | position: absolute; 103 | right: 4px; 104 | top: calc(50% - 2px); 105 | } 106 | 107 | .ProseMirror-menu-dropdown-menu, .ProseMirror-menu-submenu { 108 | position: absolute; 109 | background: white; 110 | color: #666; 111 | border: 1px solid #aaa; 112 | padding: 2px; 113 | } 114 | 115 | .ProseMirror-menu-dropdown-menu { 116 | z-index: 15; 117 | min-width: 6em; 118 | } 119 | 120 | .ProseMirror-menu-dropdown-item { 121 | cursor: pointer; 122 | padding: 2px 8px 2px 4px; 123 | } 124 | 125 | .ProseMirror-menu-dropdown-item:hover { 126 | background: #f2f2f2; 127 | } 128 | 129 | .ProseMirror-menu-submenu-wrap { 130 | position: relative; 131 | margin-right: -4px; 132 | } 133 | 134 | .ProseMirror-menu-submenu-label:after { 135 | content: ""; 136 | border-top: 4px solid transparent; 137 | border-bottom: 4px solid transparent; 138 | border-left: 4px solid currentColor; 139 | opacity: .6; 140 | position: absolute; 141 | right: 4px; 142 | top: calc(50% - 4px); 143 | } 144 | 145 | .ProseMirror-menu-submenu { 146 | display: none; 147 | min-width: 4em; 148 | left: 100%; 149 | top: -3px; 150 | } 151 | 152 | .ProseMirror-menu-active { 153 | background: #eee; 154 | border-radius: 4px; 155 | } 156 | 157 | .ProseMirror-menu-active { 158 | background: #eee; 159 | border-radius: 4px; 160 | } 161 | 162 | .ProseMirror-menu-disabled { 163 | opacity: .3; 164 | } 165 | 166 | .ProseMirror-menu-submenu-wrap:hover .ProseMirror-menu-submenu, .ProseMirror-menu-submenu-wrap-active .ProseMirror-menu-submenu { 167 | display: block; 168 | } 169 | 170 | .ProseMirror-menubar { 171 | border-top-left-radius: inherit; 172 | border-top-right-radius: inherit; 173 | position: relative; 174 | min-height: 1em; 175 | color: #666; 176 | padding: 1px 6px; 177 | top: 0; left: 0; right: 0; 178 | border-bottom: 1px solid silver; 179 | background: white; 180 | z-index: 10; 181 | -moz-box-sizing: border-box; 182 | box-sizing: border-box; 183 | overflow: visible; 184 | } 185 | 186 | .ProseMirror-icon { 187 | display: inline-block; 188 | line-height: .8; 189 | vertical-align: -2px; /* Compensate for padding */ 190 | padding: 2px 8px; 191 | cursor: pointer; 192 | } 193 | 194 | .ProseMirror-menu-disabled.ProseMirror-icon { 195 | cursor: default; 196 | } 197 | 198 | .ProseMirror-icon svg { 199 | fill: currentColor; 200 | height: 1em; 201 | } 202 | 203 | .ProseMirror-icon span { 204 | vertical-align: text-top; 205 | } 206 | /* Add space around the hr to make clicking it easier */ 207 | 208 | .ProseMirror-example-setup-style hr { 209 | position: relative; 210 | height: 6px; 211 | border: none; 212 | } 213 | 214 | .ProseMirror-example-setup-style hr:after { 215 | content: ""; 216 | position: absolute; 217 | left: 10px; 218 | right: 10px; 219 | top: 2px; 220 | border-top: 2px solid silver; 221 | } 222 | 223 | .ProseMirror ul, .ProseMirror ol { 224 | padding-left: 30px; 225 | } 226 | 227 | .ProseMirror blockquote { 228 | padding-left: 1em; 229 | border-left: 3px solid #eee; 230 | margin-left: 0; margin-right: 0; 231 | } 232 | 233 | .ProseMirror-example-setup-style img { 234 | cursor: default; 235 | } 236 | 237 | .ProseMirror-example-setup-style table { 238 | border-collapse: collapse; 239 | } 240 | 241 | .ProseMirror-example-setup-style td { 242 | vertical-align: top; 243 | border: 1px solid #ddd; 244 | padding: 3px 5px; 245 | } 246 | 247 | .ProseMirror-prompt { 248 | background: white; 249 | padding: 5px 10px 5px 15px; 250 | border: 1px solid silver; 251 | position: fixed; 252 | border-radius: 3px; 253 | z-index: 11; 254 | box-shadow: -.5px 2px 5px rgba(0, 0, 0, .2); 255 | } 256 | 257 | .ProseMirror-prompt h5 { 258 | margin: 0; 259 | font-weight: normal; 260 | font-size: 100%; 261 | color: #444; 262 | } 263 | 264 | .ProseMirror-prompt input[type="text"], 265 | .ProseMirror-prompt textarea { 266 | background: #eee; 267 | border: none; 268 | outline: none; 269 | } 270 | 271 | .ProseMirror-prompt input[type="text"] { 272 | padding: 0 4px; 273 | } 274 | 275 | .ProseMirror-prompt-close { 276 | position: absolute; 277 | left: 2px; top: 1px; 278 | color: #666; 279 | border: none; background: transparent; padding: 0; 280 | } 281 | 282 | .ProseMirror-prompt-close:after { 283 | content: "✕"; 284 | font-size: 12px; 285 | } 286 | 287 | .ProseMirror-invalid { 288 | background: #ffc; 289 | border: 1px solid #cc7; 290 | border-radius: 4px; 291 | padding: 5px 10px; 292 | position: absolute; 293 | min-width: 10em; 294 | } 295 | 296 | .ProseMirror-prompt-buttons { 297 | margin-top: 5px; 298 | display: none; 299 | } 300 | -------------------------------------------------------------------------------- /preferences.js: -------------------------------------------------------------------------------- 1 | const load = require('fetch-js') 2 | 3 | /* preferences */ 4 | const defaultPreferences = { 5 | // most functions must return Promises. 6 | 7 | // this function defines how the customization file will be loaded 8 | // from the repository that is being edited. It doesn't make sense 9 | // to change it unless you're hosting your own version of coisas. 10 | loadPreferences: ctx => new Promise((resolve, reject) => { 11 | let repoSlug = `${ctx.params.owner}/${ctx.params.repo}` 12 | 13 | load(`https://rawgit.com/${repoSlug}/master/coisas.js`, resolve) 14 | }), 15 | 16 | // if you wanna use the default authorizationInit function, but with 17 | // a different endpoint -- you should perhaps fork the default endpoint 18 | // at Glitch and run your own, with your own API keys --, you can just 19 | // modify this URL. 20 | authorizationURL: 'https://steadfast-banana.glitch.me/auth', 21 | 22 | // this is the function that will be called when the user clicks at the 23 | // 'authorize with GitHub" button in the navbar. It should somehow fetch 24 | // a token and store it in the browser, or in memory, so it can be fetched 25 | // later with authorizationLoad and deleted with authorizationRemove. 26 | // the token doesn't have to be an OAuth token, it may be a GitHub Personal 27 | // User Token, or a basic token made from username and password. 28 | authorizationInit: () => new Promise((resolve, reject) => { 29 | let popup = window.open(window.coisas.authorizationURL) 30 | window.addEventListener('message', e => { 31 | let parts = e.data.split(':') 32 | let type = parts[0] 33 | if (type === 'authorizing') { 34 | popup.postMessage('send me the token!', window.coisas.authorizationURL) 35 | return 36 | } 37 | 38 | let status = parts[2] 39 | let content = JSON.parse(parts.slice(3).join(':')) 40 | 41 | if (status === 'success') { 42 | localStorage.setItem('gh_oauth_token', content.token) 43 | resolve() 44 | } else { 45 | console.error(content) 46 | reject() 47 | } 48 | popup.close() 49 | }) 50 | }), 51 | 52 | // this function is called every time a call is going to be made to 53 | // the GitHub API. It must return a Promise to the full content of the 54 | // 'Authorization' header that will be sent to GitHub. 55 | // You can almost safely return a rejected promise for GET calls, but 56 | // since GitHub may rate-limit you and for all PUT requests a valid header 57 | // is required it is better if you can return a valid header always. 58 | authorizationLoad: (method) => new Promise((resolve, reject) => { 59 | let storedToken = localStorage.getItem('gh_oauth_token') 60 | if (storedToken) return resolve('token ' + storedToken) 61 | else reject() 62 | }), 63 | 64 | // called when the user clicks on 'logout'. should remove the token if it 65 | // is stored in the browser or in memory. 66 | authorizationRemove: () => new Promise((resolve) => { 67 | localStorage.removeItem('gh_oauth_token') 68 | resolve() 69 | }), 70 | 71 | // called every time the user clicks at "+ new file". should return 72 | // an object with name, content and metadata, these values will be the 73 | // default values for the file that is being crated (or you can just 74 | // return everything empty and the new potential file will have no default 75 | // values, it doesn't mean much). 76 | defaultNewFile: (dirPath) => Promise.resolve({ 77 | name: `new-article-${parseInt(Math.random() * 100)}.md`, 78 | content: '~ write something here.', 79 | metadata: { 80 | title: 'New Article', 81 | date: (new Date()).toISOString().split('T')[0] 82 | } 83 | }), 84 | 85 | // which files will appear at the left tree. use it to exclude files you 86 | // don't want to edit in the CMS, like code, or binary files. 87 | // the tree is an array of file definitions which will call .filter() 88 | // on this function. 89 | // see https://developer.github.com/v3/git/trees/#get-a-tree-recursively 90 | // to know what the 'tree' is exactly. 91 | filterTreeFiles: file => true, 92 | 93 | // the path to a directory into which all files uploaded through the sidebar 94 | // upload widget will be placed. 95 | defaultMediaUploadPath: 'media', 96 | 97 | // if set, a link to the live site will appear in navbar. 98 | liveSiteURL: null, 99 | 100 | // for each file opened in the editor you can return true or false here 101 | // to determine if the Edit/Preview buttons will be displayed or not. 102 | canPreview: ( 103 | path /* this is the relative path of the file in the repo, like '_posts/hi.md' */, 104 | ext /* the file extension, like '.md' */, 105 | isnew /* true if the file is not on the repo, but just being created now */ 106 | ) => false, 107 | 108 | // this function takes a raw HTMLElement and a context object with basically all 109 | // the data the editor has and must render something to that element (for example, 110 | // using `element.innerHTML = 'something'`). 111 | // you probably cannot replicate your entire static website generator in this 112 | // single Javascript function, and you'll also will be in trouble if you need 113 | // to access the contents of all the other pages in the site (for example, if 114 | // you were trying to generate a preview of an index page that shows excerpts), 115 | // but for blog posts and basic content pages you can do a fine job here. also, 116 | // for complicated pages you can probably use fake content where it is missing. 117 | generatePreview: (element, { 118 | path /* the relative path of the file being rendered, like '_posts/hi.md' */, 119 | name /* the filename, like 'hi.md' */, 120 | ext /* the file extension, like '.md' */, 121 | mime /* the mimetype, based on the extension, like 'text/x-markdown' */, 122 | content /* the raw, written content (without frontmatter) */, 123 | metadata /* an object with the metadata, if any, taken from the frontmatter */, 124 | repo /* the current GitHub repository slug, like 'fiatjaf/coisas' */, 125 | tree /* a list of the files in the site, as returned by 126 | https://developer.github.com/v3/git/trees/#get-a-tree-recursively */, 127 | edited /* an object with all the current edited, probably still unsaved, 128 | file contents and metadata, keyed by their path. if a file has 129 | been opened and edited in this current session of _coisas_, it will 130 | be here, otherwise it won't. 131 | like {_posts/what.md: {content: "nada", metadata: {title: "What?"}}} 132 | */ 133 | }) => {} 134 | } 135 | 136 | // module loading side-effects are great. 137 | if (window.coisas) { 138 | // someone have injected his preferences directly. 139 | // this must mean coisas is being hosted somewhere 140 | window.coisas = {...defaultPreferences, ...window.coisas} 141 | } else { 142 | // no settings found, we will fetch the settings 143 | // loader from the chosen repository. 144 | window.coisas = defaultPreferences 145 | } 146 | -------------------------------------------------------------------------------- /state.js: -------------------------------------------------------------------------------- 1 | const page = require('page') 2 | const {observable, computed, autorun} = require('mobx') 3 | const matter = require('gray-matter') 4 | const mimeTypes = require('render-media/lib/mime.json') 5 | 6 | const gh = require('./helpers/github') 7 | const {ADD, REPLACE, UPLOAD, EDIT, DIRECTORY} = require('./constants').modes 8 | const base64 = require('./helpers/base64') 9 | const log = require('./log') 10 | const storage = require('./helpers/storage') 11 | 12 | /* STATE */ 13 | 14 | var state = { 15 | loggedUser: observable.box(null), 16 | 17 | route: observable.box({ 18 | componentName: 'div', 19 | ctx: {params: {}} 20 | }), 21 | 22 | owner: computed(() => state.route.get().ctx.params.owner), 23 | repo: computed(() => state.route.get().ctx.params.repo), 24 | slug: computed(() => 25 | state.repo.get() 26 | ? state.owner.get() + '/' + state.repo.get() 27 | : null 28 | ), 29 | 30 | editedValues: observable.box({}), 31 | 32 | tree: observable.box([]), 33 | bypath: computed(() => { 34 | var bypath = {} 35 | for (let i = 0; i < state.tree.get().length; i++) { 36 | let f = state.tree.get()[i] 37 | bypath[f.path] = f 38 | } 39 | return bypath 40 | }), 41 | images: computed(() => state.tree.get().filter(f => f.path.match(/(jpe?g|gif|png|svg)$/))), 42 | 43 | mode: observable.box(ADD), 44 | existing: computed(() => { 45 | switch (state.mode.get()) { 46 | case EDIT: 47 | case REPLACE: 48 | case DIRECTORY: 49 | return true 50 | case ADD: 51 | case UPLOAD: 52 | return false 53 | } 54 | }), 55 | fullscreen: observable.box(false), 56 | 57 | current: { 58 | directory: observable.box(''), 59 | gh_contents: observable.box(null), 60 | givenName: observable.box(''), 61 | 62 | path: computed(() => 63 | state.current.gh_contents.get() 64 | ? state.current.gh_contents.get().path 65 | : [state.current.directory.get(), state.current.givenName.get()] 66 | .filter(x => x) 67 | .join('/') 68 | ), 69 | name: computed(() => state.current.path.get().split('/').slice(-1)[0]), 70 | ext: computed(() => 71 | state.current.name.get().split('.')[1] 72 | ? '.' + state.current.name.get().split('.')[1] 73 | : '' 74 | ), 75 | mime: computed(() => mimeTypes[state.current.ext.get()]), 76 | frontmatter: computed(() => 77 | state.current.mime.get() === 'text/x-markdown' || 78 | state.current.mime.get() === 'text/html' 79 | ), 80 | editable: computed(() => 81 | (state.mode.get() === ADD || state.mode.get() === EDIT) && ({ 82 | 'text/x-markdown': true, 83 | 'text/html': true, 84 | 'text/plain': true, 85 | 'text/css': true, 86 | 'text/yaml': true, 87 | 'application/json': true, 88 | 'application/javascript': true 89 | })[state.current.mime.get()]), 90 | 91 | deleting: observable.box(false), 92 | previewing: observable.box(false), 93 | loading: computed(() => 94 | state.existing.get() && !state.current.gh_contents.get() 95 | ), 96 | 97 | data: computed(() => { 98 | let r = state.current.gh_contents.get() 99 | if (!r || !r.content) return '' 100 | 101 | try { 102 | return base64.decode(r.content) 103 | } catch (e) { 104 | console.warn(e) 105 | return null 106 | } 107 | }), 108 | upload: { 109 | file: observable.box(null), 110 | base64: observable.box(null) 111 | }, 112 | 113 | edited: { 114 | set (what, val) { 115 | state.current.path.get() // side-effects to the rescue 116 | 117 | let ed = {...{}, ...state.editedValues.get()} 118 | let cur = location.hash 119 | let th = ed[cur] || {} 120 | 121 | switch (what) { 122 | case 'content': 123 | th.content = val 124 | ed[cur] = th 125 | state.editedValues.set(ed) 126 | break 127 | case 'metadata': 128 | th.metadata = th.metadata || {} 129 | th.metadata = val 130 | ed[cur] = th 131 | state.editedValues.set(ed) 132 | break 133 | } 134 | }, 135 | content: computed(() => { 136 | state.current.path.get() // side-effects to the rescue 137 | 138 | let cur = location.hash 139 | let th = state.editedValues.get()[cur] || {} 140 | return th.content || null 141 | }), 142 | metadata: computed(() => { 143 | state.current.path.get() // side-effects to the rescue 144 | 145 | let cur = location.hash 146 | let th = state.editedValues.get()[cur] || {} 147 | return th.metadata || null 148 | }) 149 | }, 150 | 151 | stored: computed(() => { 152 | let data = state.current.data.get() 153 | if (!data) return {} 154 | if (state.current.frontmatter.get()) { 155 | try { 156 | let {data: metadata, content} = matter(data) 157 | return {metadata, content} 158 | } catch (e) { 159 | return {metadata: {}, content: data} 160 | } 161 | } else { 162 | return {content: data} 163 | } 164 | }), 165 | shown: { 166 | content: computed(() => 167 | typeof state.current.edited.content.get() === 'string' 168 | ? state.current.edited.content.get() 169 | : state.current.stored.get().content 170 | ), 171 | metadata: computed(() => 172 | state.current.edited.metadata.get() || state.current.stored.get().metadata || {}) 173 | } 174 | }, 175 | 176 | mediaUpload: { 177 | file: observable.box(null), 178 | base64: observable.box(null) 179 | } 180 | } 181 | module.exports = state 182 | 183 | 184 | /* REACTIONS */ 185 | 186 | autorun(() => { 187 | if (!state.tree.get().length) return 188 | 189 | let res = state.current.gh_contents.get() 190 | if (!res) return 191 | 192 | resetTreeForCurrent() 193 | }) 194 | 195 | 196 | /* ACTIONS */ 197 | 198 | module.exports.clearCurrent = clearCurrent 199 | function clearCurrent () { 200 | state.current.deleting.set(false) 201 | state.current.previewing.set(false) 202 | state.current.directory.set('') 203 | state.current.gh_contents.set(null) 204 | state.current.givenName.set('') 205 | state.current.upload.file.set(null) 206 | state.current.upload.base64.set(null) 207 | } 208 | 209 | module.exports.loadFile = loadFile 210 | function loadFile (path) { 211 | log.info(`Loading ${path} from GitHub.`) 212 | return gh.get(`repos/${state.slug.get()}/contents/${path}`, {ref: 'master'}) 213 | .then(res => { 214 | if (res.path) { 215 | state.current.gh_contents.set(res) 216 | state.mode.set(EDIT) 217 | log.info(`Loaded ${path}.`) 218 | } else if (Array.isArray(res)) { 219 | state.current.directory.set(path) 220 | state.mode.set(DIRECTORY) 221 | } else { 222 | console.error('Got a strange result:', res) 223 | } 224 | }) 225 | .catch(err => { 226 | console.log(err) 227 | }) 228 | } 229 | 230 | module.exports.newFile = newFile 231 | function newFile (dirpath) { 232 | return window.coisas.defaultNewFile(dirpath) 233 | .then(({name, content, metadata}) => { 234 | clearCurrent() 235 | state.current.directory.set(dirpath) 236 | state.current.givenName.set(name) 237 | state.mode.set(ADD) 238 | 239 | setTimeout(() => { 240 | if (state.current.edited.content.get() === null) { 241 | state.current.edited.set('content', content) 242 | state.current.edited.set('metadata', metadata) 243 | } 244 | }, 1) 245 | }) 246 | } 247 | 248 | module.exports.loadTree = loadTree 249 | function loadTree () { 250 | return gh.get(`repos/${state.slug.get()}/git/refs/heads/master`) 251 | .then(ref => 252 | gh.get( 253 | `repos/${state.slug.get()}/git/trees/${ref.object.sha}`, 254 | {recursive: 5} 255 | ) 256 | ) 257 | .then(tree => { 258 | // add a fake top level dir 259 | tree.tree.unshift({ 260 | mode: '040000', 261 | path: '', 262 | sha: '~', 263 | type: 'tree', 264 | url: '~' 265 | }) 266 | 267 | tree.tree = tree.tree.filter(window.coisas.filterTreeFiles) 268 | 269 | for (let i = 0; i < tree.tree.length; i++) { 270 | let f = tree.tree[i] 271 | f.collapsed = true 272 | f.active = false 273 | } 274 | 275 | state.tree.set( 276 | // sort to show directories first 277 | tree.tree.sort((a, b) => { 278 | if (a.type === 'blob' && b.type === 'tree') return 1 279 | else if (a.type === 'tree' && b.type === 'blob') return -1 280 | else return a.path < b.path ? -1 : 1 281 | }) 282 | ) 283 | }) 284 | .catch(log.error) 285 | } 286 | 287 | module.exports.resetTreeForCurrent = resetTreeForCurrent 288 | function resetTreeForCurrent () { 289 | let path = state.current.path.get() 290 | 291 | var updatedTree = [] 292 | for (let i = 0; i < state.tree.get().length; i++) { 293 | let f = state.tree.get()[i] 294 | 295 | // open all directories up to the selected file 296 | if (path.slice(0, f.path.length) === f.path) { 297 | f.collapsed = false 298 | } 299 | 300 | // reset active state 301 | f.active = false 302 | 303 | updatedTree.push(f) 304 | } 305 | 306 | if (state.existing.get()) { 307 | // mark the currently selected file as active 308 | state.bypath.get()[path].active = true 309 | } else { 310 | // mark the currently selected directory as active 311 | state.bypath.get()[state.current.directory.get()].active = true 312 | } 313 | 314 | state.tree.set(updatedTree) 315 | } 316 | 317 | module.exports.loadUser = loadUser 318 | function loadUser () { 319 | return gh.get('user') 320 | .then(res => { 321 | state.loggedUser.set(res.login) 322 | log.info(`Logged as ${res.login}.`) 323 | }) 324 | .catch(e => { 325 | console.log('could not load GitHub token or used an invalid token.', e) 326 | state.loggedUser.set(null) 327 | }) 328 | } 329 | 330 | 331 | /* ROUTES */ 332 | 333 | page('/', ctx => state.route.set({componentName: 'index', ctx})) 334 | page('/:owner/:repo/*', ctx => { 335 | window.tc && window.tc(2) 336 | const repoName = ctx.params.owner + '/' + ctx.params.repo 337 | storage.storeRepo(repoName) 338 | window.coisas.loadPreferences(ctx) 339 | .then(() => { 340 | state.route.set({componentName: 'repo', ctx}) 341 | 342 | if (navigator.serviceWorker.controller) { 343 | navigator.serviceWorker.controller.postMessage({ 344 | currentRepo: state.slug.get() 345 | }) 346 | } 347 | 348 | let [k, dirpath] = ctx.querystring.split('=') 349 | let filePromise = k === 'new-file-at' 350 | ? newFile(dirpath) 351 | : loadFile(ctx.params[0]) 352 | 353 | loadUser() 354 | Promise.all([ 355 | filePromise, 356 | loadTree() 357 | ]).then(() => { 358 | resetTreeForCurrent() 359 | }) 360 | }) 361 | }) 362 | page({hashbang: true}) 363 | -------------------------------------------------------------------------------- /components/Repo.js: -------------------------------------------------------------------------------- 1 | const h = require('react-hyperscript') 2 | const CodeMirror = require('react-codemirror') 3 | const Json = require('react-json') 4 | const TreeView = require('react-treeview') 5 | const {observer} = require('mobx-react') 6 | const fwitch = require('fwitch') 7 | 8 | const {ADD, REPLACE, UPLOAD, EDIT, DIRECTORY} = require('../constants').modes 9 | const {loadTree, resetTreeForCurrent, loadFile, newFile, clearCurrent} = require('../state') 10 | const renderWithFrontmatter = require('../helpers/render-with-frontmatter') 11 | const ProseMirror = require('./ProseMirror') 12 | const FileUpload = require('./FileUpload') 13 | const Preview = require('./Preview') 14 | const base64 = require('../helpers/base64') 15 | const state = require('../state') 16 | const log = require('../log') 17 | const gh = require('../helpers/github') 18 | 19 | module.exports = observer(function Repo () { 20 | return h('.columns.is-mobile', [ 21 | h('.column.is-3', [ 22 | h(Menu, {name: 'menu'}), 23 | h(Save) 24 | ]), 25 | h('.column.is-7', [ 26 | state.current.loading.get() 27 | ? h('div') 28 | : fwitch(state.mode.get(), { 29 | [ADD]: h(Page), 30 | [REPLACE]: h(Upload), 31 | [UPLOAD]: h(Upload), 32 | [EDIT]: h(Page), 33 | [DIRECTORY]: h(Directory) 34 | }) 35 | ]), 36 | h('.column.is-2', [ 37 | h(Images) 38 | ]) 39 | ]) 40 | }) 41 | 42 | const Menu = observer(function Menu () { 43 | let topdir = state.bypath.get()[''] 44 | 45 | if (!topdir) return h('div') 46 | 47 | return h('#Menu.menu', [ 48 | h('ul.menu-list', state.tree.get() 49 | .filter(f => f.path) 50 | .filter(f => f.path.split('/').length === 1) 51 | .map(f => h(Folder, {f})) 52 | .concat( 53 | h('li', [ 54 | h(ButtonAdd, {dir: topdir, active: topdir.active}) 55 | ]) 56 | ) 57 | ) 58 | ]) 59 | }) 60 | 61 | const Folder = observer(function Folder ({f}) { 62 | if (f.type === 'blob') { 63 | return h('li', { 64 | key: f.path 65 | }, [ 66 | h('a', { 67 | className: f.active ? 'is-active' : '', 68 | href: `#!/${state.slug.get()}/${f.path}`, 69 | onClick: () => { 70 | clearCurrent() 71 | loadFile(f.path) 72 | } 73 | }, f.path.split('/').slice(-1)[0]) 74 | ]) 75 | } 76 | 77 | let dir = f 78 | return ( 79 | h(TreeView, { 80 | nodeLabel: dir.path.split('/').slice(-1)[0], 81 | collapsed: dir.collapsed, 82 | onClick: () => { 83 | state.bypath.get()[dir.path].collapsed = !dir.collapsed 84 | state.tree.set(state.tree.get().concat() /* copy the array */) 85 | } 86 | }, [ 87 | h('ul.menu-list', state.tree.get() 88 | .filter(f => 89 | f.path.slice(0, dir.path.length + 1) === dir.path + '/' && 90 | f.path.split('/').length - 1 === dir.path.split('/').length 91 | ) 92 | .map(f => h(Folder, {key: f.path, f})) 93 | .concat( 94 | h('li', [ 95 | h(ButtonAdd, {dir, active: dir.active}) 96 | ]) 97 | ) 98 | ) 99 | ]) 100 | ) 101 | }) 102 | 103 | const ButtonAdd = observer(function ButtonAdd ({dir, active}) { 104 | return h('a', { 105 | className: active ? 'is-active' : '', 106 | href: `#!/${state.slug.get()}/?new-file-at=${dir.path}`, 107 | onClick: () => newFile(dir.path).then(resetTreeForCurrent) 108 | }, '+ new file') 109 | }) 110 | 111 | const Delete = observer(function Delete () { 112 | if (!state.current.deleting.get()) { 113 | return h('#Delete', [ 114 | h('.level', [ 115 | h('.level-left', [ 116 | h('button.button', { 117 | onClick: () => { 118 | state.current.deleting.set(true) 119 | } 120 | }, 'Delete this?') 121 | ]) 122 | ]) 123 | ]) 124 | } 125 | 126 | return h('#Delete', [ 127 | h('p', `Delete ${state.current.path.get()}?`), 128 | h('.level', [ 129 | h('.level-left', [ 130 | h('button.button.delete-cancel.is-small', { 131 | onClick: () => state.current.deleting.set(false) 132 | }, 'Cancel') 133 | ]), 134 | h('.level-right', [ 135 | h('button.button.delete-confirm.is-large.is-danger', { 136 | onClick: () => { 137 | let path = state.current.path.get() 138 | 139 | let currentTree = state.tree.get() 140 | .filter(f => f.type === 'blob') 141 | .sort((a, b) => a.path < b.path ? -1 : 1) 142 | let currentFile = state.bypath.get()[path] 143 | let currentIndex = currentTree.indexOf(currentFile) 144 | let nextIndex = currentIndex === 0 ? 1 : currentIndex - 1 145 | let nextPath = currentTree[nextIndex].path 146 | 147 | log.info(`Deleting ${path}.`) 148 | gh.delete(`repos/${state.slug.get()}/contents/${path}`, { 149 | sha: state.current.gh_contents.get().sha, 150 | message: `deleted ${path}.` 151 | }) 152 | .then(() => { 153 | log.success('Deleted.') 154 | clearCurrent() 155 | location.hash = `#!/${state.slug.get()}/${nextPath}` 156 | return Promise.all([ 157 | loadFile(nextPath), 158 | loadTree() 159 | ]) 160 | }) 161 | .then(resetTreeForCurrent) 162 | .catch(log.error) 163 | } 164 | }, 'Delete') 165 | ]) 166 | ]) 167 | ]) 168 | }) 169 | 170 | const Upload = observer(function Upload () { 171 | if (window.coisas.defaultMediaUploadPath || 172 | window.coisas.defaultMediaUploadPath === '') { 173 | return h('#Upload', [ 174 | h(Title), 175 | h('.upload', [ 176 | h('label', [ 177 | state.current.upload.file.get() 178 | ? h('div', [ 179 | h(Preview, { 180 | name: state.current.upload.file.get().name, 181 | blob: state.current.upload.file.get() 182 | }) 183 | ]) 184 | : h(FileUpload, { 185 | onFile: f => { 186 | state.current.upload.file.set(f) 187 | state.current.givenName.set(f.name) 188 | }, 189 | onBase64: b64 => state.current.upload.base64.set(b64) 190 | }) 191 | ]) 192 | ]) 193 | ]) 194 | } 195 | }) 196 | 197 | const Title = observer(function Title () { 198 | var buttons = [] 199 | 200 | if (state.current.editable.get()) { 201 | buttons.push( 202 | h('p.control', {key: 'fullscreen', onClick: () => state.fullscreen.set(!state.fullscreen.get())}, [ 203 | h('button.button.is-primary.is-small.is-inverted.button-list', { 204 | className: state.fullscreen.get() ? '' : 'is-outlined' 205 | }, [ 206 | h('span.icon.is-small', [ h('i.fa.fa-expand') ]), 207 | h('span', state.fullscreen.get() ? 'Collapse' : 'Expand') 208 | ]) 209 | ]) 210 | ) 211 | } 212 | 213 | if ( 214 | state.current.editable.get() && 215 | (state.mode.get() === EDIT || state.mode.get() === ADD) && 216 | window.coisas.canPreview( 217 | state.current.path.get(), 218 | state.current.ext.get(), 219 | !state.existing.get() 220 | ) 221 | ) { 222 | buttons.push( 223 | h('p.control', {key: 'edit', onClick: () => state.current.previewing.set(false)}, [ 224 | h('button.button.is-info.is-small.is-inverted.button-list', { 225 | className: state.current.previewing.get() ? 'is-outlined' : '' 226 | }, [ 227 | h('span.icon.is-small', [ h('i.fa.fa-pencil-square') ]), 228 | h('span', 'Edit') 229 | ]) 230 | ]) 231 | ) 232 | buttons.push( 233 | h('p.control', {key: 'preview', onClick: () => state.current.previewing.set(true)}, [ 234 | h('button.button.is-warning.is-small.is-inverted.button-list', { 235 | className: state.current.previewing.get() ? '' : 'is-outlined' 236 | }, [ 237 | h('span.icon.is-small', [ h('i.fa.fa-eye') ]), 238 | h('span', 'Preview') 239 | ]) 240 | ]) 241 | ) 242 | } 243 | 244 | var title = state.existing.get() 245 | ? h('h3.title.is-3', state.current.path.get()) 246 | : h('input.input.is-large', { 247 | value: state.current.name.get(), 248 | onChange: e => state.current.givenName.set(e.target.value) 249 | }) 250 | 251 | return h('.level', [ 252 | h('.level-left', [ title ]), 253 | h('.level-right', [ 254 | h('.level-item', [ 255 | h('.field.has-addons', [ buttons ]) 256 | ]) 257 | ]) 258 | ]) 259 | }) 260 | 261 | const PagePreview = observer(function PagePreview () { 262 | return h('#PagePreview', { 263 | ref: el => { 264 | if (el) { 265 | window.coisas.generatePreview(el, { 266 | path: state.current.path.get(), 267 | name: state.current.name.get(), 268 | ext: state.current.ext.get(), 269 | mime: state.current.mime.get(), 270 | content: state.current.shown.content.get(), 271 | metadata: state.current.shown.metadata.get(), 272 | slug: state.slug.get(), 273 | tree: state.tree.get(), 274 | edited: state.editedValues.get() 275 | }) 276 | } 277 | } 278 | }) 279 | }) 280 | 281 | const Page = observer(function Page () { 282 | if (state.current.previewing.get()) { 283 | components = [ h(PagePreview) ] 284 | } else { 285 | var editor 286 | switch (state.current.mime.get()) { 287 | case 'text/x-markdown': 288 | editor = h(EditMarkdown) 289 | break 290 | case 'text/html': 291 | case 'text/plain': 292 | case 'text/css': 293 | case 'text/yaml': 294 | case 'application/json': 295 | case 'application/javascript': 296 | case undefined: 297 | editor = h(EditCode) 298 | break 299 | } 300 | 301 | var preview 302 | try { 303 | preview = h(Preview, { 304 | name: state.current.name.get(), 305 | base64: state.current.gh_contents.get().content 306 | }) 307 | } catch (e) {} 308 | 309 | var components = editor 310 | ? [ editor ] 311 | : [ preview ] 312 | 313 | let uploadMode = state.existing.get() ? REPLACE : UPLOAD 314 | var buttons = [] 315 | buttons.push( 316 | h('.level-left', [ 317 | h('button.button.upload', { 318 | onClick: () => { 319 | state.mode.set(uploadMode) 320 | } 321 | }, 322 | state.existing.get() 323 | ? 'Replace with an uploaded file' 324 | : 'Upload a file' 325 | ) 326 | ]) 327 | ) 328 | 329 | if (state.existing.get()) { 330 | buttons.push( 331 | h('.level-right', [ h(Delete) ]) 332 | ) 333 | } 334 | 335 | components.push(h('.level', buttons)) 336 | } 337 | 338 | return h('#Page', { 339 | className: state.fullscreen.get() ? 'fullscreen' : '' 340 | }, [ 341 | h(Title), 342 | h('div', components) 343 | ]) 344 | }) 345 | 346 | const EditMarkdown = observer(function EditMarkdown () { 347 | if (state.current.loading.get()) { 348 | return h('div') 349 | } 350 | 351 | return h('#EditMarkdown.content', [ 352 | h(Json, { 353 | value: state.current.shown.metadata.get(), 354 | onChange: metadata => { 355 | state.current.edited.set('metadata', metadata) 356 | } 357 | }), 358 | h(ProseMirror, { 359 | defaultValue: state.current.shown.content.get(), 360 | onChange: content => { 361 | state.current.edited.set('content', content) 362 | } 363 | }) 364 | ]) 365 | }) 366 | 367 | const EditCode = observer(function EditCode () { 368 | if (state.current.shown.content.get() === null) { 369 | return h('div', 'cannot render this file here.') 370 | } 371 | 372 | if (state.current.loading.get()) { 373 | return h('div') 374 | } 375 | 376 | let value = state.current.shown.content.get() 377 | 378 | return h('#EditCode.content', [ 379 | state.current.frontmatter.get() && h(Json, { 380 | value: state.current.shown.metadata.get(), 381 | onChange: metadata => { 382 | state.current.edited.set('metadata', metadata) 383 | } 384 | }), 385 | h(CodeMirror, { 386 | value, 387 | onChange: v => state.current.edited.set('content', v), 388 | options: { 389 | viewportMargin: Infinity, 390 | mode: state.current.mime.get() 391 | } 392 | }) 393 | ]) 394 | }) 395 | 396 | const Directory = observer(function Directory () { 397 | return h('#Directory', [ 398 | h('center', [ 399 | h('br'), 400 | h('p', 'Use the tree on the left to select a file or create a new one.') 401 | ]) 402 | ]) 403 | }) 404 | 405 | const Images = observer(function Images () { 406 | let images = state.images.get() 407 | let mid = parseInt(images.length / 2) 408 | 409 | const renderImage = f => 410 | h('img', { 411 | key: f.path, 412 | src: `https://raw.githubusercontent.com/${state.slug.get()}/master/${f.path}`, 413 | title: f.path, 414 | onDoubleClick: () => { 415 | clearCurrent() 416 | loadFile(f.path) 417 | location.hash = `#!/${state.slug.get()}/${f.path}` 418 | } 419 | }) 420 | 421 | return h('#Images', [ 422 | images.length 423 | ? 'drag an image from here to the editor to insert it.' 424 | : '', 425 | h('.columns', [ 426 | h('.column.is-half', images.slice(0, mid).map(renderImage)), 427 | h('.column.is-half', images.slice(mid).map(renderImage)) 428 | ]), 429 | state.mediaUpload.file.get() 430 | ? h(Preview, { 431 | name: state.mediaUpload.file.get().name, 432 | blob: state.mediaUpload.file.get() 433 | }, [ 434 | h('.level', [ 435 | h('.level-left', [ 436 | h('button.button.is-small.is-light', { 437 | onClick: () => { 438 | state.mediaUpload.file.set(null) 439 | state.mediaUpload.base64.set(null) 440 | } 441 | }, 'cancel') 442 | ]), 443 | h('.level-right', [ 444 | h('button.button.is-small.is-info', { 445 | onClick: () => { 446 | let file = state.mediaUpload.file.get() 447 | log.info(`Uploading ${file.name} to ${window.coisas.defaultMediaUploadPath}/.`) 448 | 449 | gh.saveFile({ 450 | repoSlug: state.slug.get(), 451 | mode: UPLOAD, 452 | path: `${window.coisas.defaultMediaUploadPath}/${file.name}`, 453 | content: state.mediaUpload.base64.get() 454 | }) 455 | .then(() => { 456 | loadTree() 457 | state.mediaUpload.file.set(null) 458 | state.mediaUpload.base64.set(null) 459 | log.success('Uploaded.') 460 | }) 461 | .catch(log.error) 462 | } 463 | }, 'upload') 464 | ]) 465 | ]) 466 | ]) 467 | : h(FileUpload, { 468 | onFile: f => state.mediaUpload.file.set(f), 469 | onBase64: b64 => state.mediaUpload.base64.set(b64) 470 | }) 471 | ]) 472 | }) 473 | 474 | const Save = observer(function Save () { 475 | var disabled = true 476 | if (state.current.edited.content.get() && 477 | state.current.edited.content.get() !== state.current.stored.get().content) { 478 | disabled = false 479 | } 480 | if (Object.keys(state.current.edited.metadata.get() || {}).length) disabled = false 481 | if (state.current.upload.base64.get()) disabled = false 482 | 483 | return h('#Save', [ 484 | h('button.button.is-large', { 485 | disabled, 486 | onClick: () => { 487 | log.info(`Saving ${state.current.path.get()} to GitHub.`) 488 | gh.saveFile({ 489 | repoSlug: state.slug.get(), 490 | mode: state.mode.get(), 491 | path: state.current.path.get(), 492 | sha: state.current.gh_contents.get() 493 | ? state.current.gh_contents.get().sha 494 | : undefined, 495 | content: state.current.upload.base64.get() 496 | ? state.current.upload.base64.get() 497 | : state.current.frontmatter.get() 498 | ? base64.encode( 499 | renderWithFrontmatter( 500 | state.current.shown.content.get(), 501 | state.current.shown.metadata.get(), 502 | state.slug.get() 503 | ) 504 | ) 505 | : base64.encode(state.current.shown.content.get()) 506 | }) 507 | .then(() => { 508 | if (state.mode.get() === ADD || state.mode.get() === UPLOAD) { 509 | return Promise.resolve() 510 | .then(loadTree) 511 | .then(() => loadFile(state.current.path.get())) 512 | .then(resetTreeForCurrent) 513 | } 514 | }) 515 | .then(() => log.success('Saved.')) 516 | .catch(log.error) 517 | } 518 | }, fwitch(state.mode.get(), { 519 | [ADD]: 'Create file', 520 | [REPLACE]: 'Replace file', 521 | [UPLOAD]: 'Upload', 522 | [EDIT]: 'Save changes' 523 | })) 524 | ]) 525 | }) 526 | --------------------------------------------------------------------------------