├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── app.js ├── bin ├── build └── www ├── common ├── at.js ├── page.js ├── storage.js ├── tools.js └── upload.js ├── config_default.js ├── jsconfig.json ├── middlewares ├── jade_partial.js ├── return_data.js ├── sign.js └── stylus.js ├── models ├── index.js ├── message.js ├── reply.js ├── topic.js └── user.js ├── package-lock.json ├── package.json ├── public ├── images │ ├── icon.png │ ├── logo.png │ └── photo.png ├── javascripts │ ├── jquery.loadData.js │ ├── jquery.modal.js │ ├── lib │ │ ├── editor │ │ │ ├── editor.css │ │ │ ├── editor.js │ │ │ ├── ext.js │ │ │ └── fonts │ │ │ │ ├── icomoon.dev.svg │ │ │ │ ├── icomoon.eot │ │ │ │ ├── icomoon.svg │ │ │ │ ├── icomoon.ttf │ │ │ │ └── icomoon.woff │ │ ├── jquery.atwho.js │ │ ├── jquery.caret.js │ │ ├── jquery.min.js │ │ ├── markdown-it.js │ │ └── webuploader │ │ │ ├── Uploader.swf │ │ │ ├── webuploader.css │ │ │ └── webuploader.withoutimage.js │ └── main.js └── stylesheets │ ├── atwho.css │ ├── common.styl │ ├── fonts │ ├── icomoon.eot │ ├── icomoon.svg │ ├── icomoon.ttf │ └── icomoon.woff │ ├── icon.css │ ├── index.styl │ ├── markdown.css │ └── variable.styl ├── routes ├── index.js ├── reply.js ├── topic.js └── user.js ├── test ├── common │ ├── at.test.js │ └── tools.test.js ├── index.test.js ├── reply.test.js ├── support │ └── support.js ├── topic.test.js └── user.test.js ├── typings.json └── views ├── alert.pug ├── includes ├── editor.pug ├── page.pug └── search.pug ├── index.pug ├── layout.pug ├── mixin ├── form-group.pug ├── label.pug └── user-home.pug ├── reply └── edit.pug ├── sidebar ├── create_topic.pug ├── markdown.pug ├── score-rank.pug ├── user_info.pug └── user_topic_list.pug ├── topic ├── edit.pug └── show.pug └── user ├── home.pug ├── home_base.pug ├── login.pug ├── message.pug ├── register.pug ├── replys.pug ├── setting.pug └── topics.pug /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | typings/ 3 | public/upload/ 4 | .vscode 5 | .DS_Store 6 | assets.json 7 | config.js 8 | public/stylesheets/fonts/icomoon.*.hashed.* 9 | public/javascripts/lib/editor/fonts/icomoon.*.hashed.* 10 | public/stylesheets/common.min.*.*.css 11 | public/javascripts/editor.min.*.*.js 12 | public/javascripts/lib/lib.min.*.*.js 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 kdylan 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 | TESTS = $(shell find test -type f -name "*.test.js") 2 | 3 | install: 4 | @npm install 5 | 6 | 7 | test: 8 | @NODE_ENV=test ./node_modules/mocha/bin/mocha \ 9 | --harmony-async-await \ 10 | $(TESTS) 11 | 12 | build: 13 | @./bin/build ./views . 14 | 15 | 16 | 17 | .PHONY: test build 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | EasyClub 2 | ----- 3 | 基于 koa2 + mongodb 的轻量级论坛系统 4 | 5 | INSTALL 6 | ---- 7 | 1. 安装`mongodb`,`Redis`和`node`(>=v7.6.0) 8 | 2. clone 该仓库到本地 9 | 3. 复制 `config.default.js` 为 `config.js` 中的配置选项 10 | 4. 运行 `npm install` 安装依赖包 11 | 5. 运行 `npm run build` 编译压缩前端文件 12 | 6. 运行 `npm run test` 跑测试 13 | 7. 执行`npm start` 使用 `nodemon` 启动,执行 `npm pm2` 使用 `pm2` 启动 14 | 15 | LICENSE 16 | ----- 17 | MIT © [kdylan](https://github.com/k-dylan) 18 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict' 3 | 4 | const Koa = require('koa'); 5 | const app = new Koa(); 6 | const router = require('koa-router')(); 7 | const Pug = require('koa-pug'); 8 | const co = require('co'); 9 | const convert = require('koa-convert'); 10 | const json = require('koa-json'); 11 | const onerror = require('koa-onerror'); 12 | const bodyparser = require('koa-bodyparser')(); 13 | const logger = require('koa-logger'); 14 | const session = require('koa-generic-session'); 15 | const redisStore = require('koa-redis'); 16 | const UglifyJS = require('uglify-js') 17 | 18 | const config = require('./config'); 19 | const index = require('./routes/index'); 20 | const user = require('./routes/user'); 21 | const topic = require('./routes/topic'); 22 | const reply = require('./routes/reply'); 23 | const tools = require('./common/tools'); 24 | 25 | 26 | const VIEWSDIR = __dirname + '/views'; 27 | 28 | let assets = {}; 29 | app.keys = ['easyclub']; 30 | 31 | onerror(app); 32 | 33 | // logger 34 | app.use(convert(logger())); 35 | 36 | 37 | // 本地调试状态 38 | if(config.debug) { 39 | app.use(require('./middlewares/stylus')(__dirname)); 40 | const livereload = require('livereload'); 41 | let server = livereload.createServer({ 42 | exts: ['jade','styl'] 43 | }); 44 | server.watch([ 45 | __dirname + '/public', 46 | VIEWSDIR 47 | ]); 48 | } else { 49 | try { 50 | assets = require('./assets.json'); 51 | } catch (e) { 52 | console.error('请先执行 make build 生成assets.json文件') 53 | throw e; 54 | } 55 | } 56 | 57 | // middlewares 58 | app.use(convert(require('koa-static2')("/public",__dirname + '/public'))); 59 | app.use(convert(bodyparser)); 60 | app.use(convert(json())); 61 | app.use(convert(session({ 62 | store: redisStore(config.redis) 63 | }))); 64 | 65 | app.use(require('./middlewares/return_data')); 66 | 67 | const pug = new Pug({ 68 | viewPath: VIEWSDIR, 69 | debug: config.debug, 70 | noCache: config.debug, 71 | helperPath: [ 72 | { 73 | getTimeAgo : tools.getTimeAgo, 74 | Loader : require('loader') 75 | } 76 | ], 77 | app: app 78 | }); 79 | // 压缩行内样式 80 | pug.options.filters = { 81 | uglify: function (text, options) { 82 | if(config.debug){ 83 | return text; 84 | } else { 85 | let result = UglifyJS.minify(text, {fromString: true}); 86 | return result.code; 87 | } 88 | } 89 | } 90 | 91 | app.use(async (ctx, next) => { 92 | if(!ctx.model) 93 | ctx.model = require('./models'); 94 | await next(); 95 | }) 96 | 97 | app.use(async (ctx, next) => { 98 | 99 | if(config.debug && ctx.cookies.get('dev-user')) { 100 | // 测试用户使用 101 | var testuser = JSON.parse(ctx.cookies.get('dev-user')); 102 | 103 | let user = await ctx.model('user').findOneQ({ 104 | username: testuser.username 105 | }); 106 | ctx.session.user = user; 107 | 108 | if(testuser.isAdmin) { 109 | ctx.session.user.isAdmin = true; 110 | } 111 | } 112 | // 添加模板变量 113 | pug.locals = Object.assign(pug.locals, { 114 | sitename: config.sitename, 115 | config: config, 116 | assets: assets, 117 | isDebug: config.debug 118 | }) 119 | 120 | 121 | if(ctx.session.user) { 122 | let user = ctx.state.current_user = ctx.session.user; 123 | // 读取at数据 124 | let messages = await ctx.model('message').find({ 125 | master_id: user._id, 126 | is_read: false 127 | }) 128 | if(messages){ 129 | user.messageLen = messages.length; 130 | } 131 | 132 | // 判断是否是管理员帐号 133 | if(config.admins.indexOf(user.username) != -1) { 134 | user.isAdmin = true; 135 | } 136 | } 137 | 138 | await next(); 139 | }); 140 | 141 | app.use(require('./middlewares/jade_partial')(VIEWSDIR)); 142 | 143 | router.use('/', index.routes(), index.allowedMethods()); 144 | router.use('/user', user.routes(), user.allowedMethods()); 145 | router.use('/topic', topic.routes(), topic.allowedMethods()); 146 | router.use('/reply', reply.routes(), topic.allowedMethods()); 147 | app.use(router.routes(), router.allowedMethods()); 148 | 149 | // response 150 | app.on('error', function(err, ctx){ 151 | console.error(err); 152 | // console.error('server error', err, ctx); 153 | }); 154 | 155 | 156 | module.exports = app; -------------------------------------------------------------------------------- /bin/build: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | /** 4 | * loader-builder 模块默认没有引用nib模块处理stylus, 5 | * 这里修改其 transform.transormStylus方法 加载 nib模块,处理stylus文件 6 | */ 7 | 8 | const transform = require('loader-builder/lib/transform'); 9 | const nib = require('nib'); 10 | const stylus = require('stylus'); 11 | 12 | /** 13 | * 调用stylus模块通过nib编译stylus文件到CSS内容 14 | * @param {String} input JavaScript source code 15 | */ 16 | transform.transformStylus = function (input) { 17 | var output; 18 | stylus(input).use(nib()).render(function (err, css) { 19 | if (err) { 20 | throw err; 21 | } 22 | output = css; 23 | }); 24 | return output; 25 | }; 26 | 27 | require('loader-builder/bin/builder'); 28 | 29 | -------------------------------------------------------------------------------- /bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | var app = require('../app'); 8 | var debug = require('debug')('demo:server'); 9 | var http = require('http'); 10 | 11 | /** 12 | * Get port from environment and store in Express. 13 | */ 14 | 15 | var port = normalizePort(process.env.PORT || '4001'); 16 | // app.set('port', port); 17 | 18 | /** 19 | * Create HTTP server. 20 | */ 21 | 22 | var server = http.createServer(app.callback()); 23 | 24 | /** 25 | * Listen on provided port, on all network interfaces. 26 | */ 27 | 28 | server.listen(port); 29 | server.on('error', onError); 30 | server.on('listening', onListening); 31 | 32 | /** 33 | * Normalize a port into a number, string, or false. 34 | */ 35 | 36 | function normalizePort(val) { 37 | var port = parseInt(val, 10); 38 | 39 | if (isNaN(port)) { 40 | // named pipe 41 | return val; 42 | } 43 | 44 | if (port >= 0) { 45 | // port number 46 | return port; 47 | } 48 | 49 | return false; 50 | } 51 | 52 | /** 53 | * Event listener for HTTP server "error" event. 54 | */ 55 | 56 | function onError(error) { 57 | if (error.syscall !== 'listen') { 58 | throw error; 59 | } 60 | 61 | var bind = typeof port === 'string' 62 | ? 'Pipe ' + port 63 | : 'Port ' + port; 64 | 65 | // handle specific listen errors with friendly messages 66 | switch (error.code) { 67 | case 'EACCES': 68 | console.error(bind + ' requires elevated privileges'); 69 | process.exit(1); 70 | break; 71 | case 'EADDRINUSE': 72 | console.error(bind + ' is already in use'); 73 | process.exit(1); 74 | break; 75 | default: 76 | throw error; 77 | } 78 | } 79 | 80 | /** 81 | * Event listener for HTTP server "listening" event. 82 | */ 83 | 84 | function onListening() { 85 | var addr = server.address(); 86 | var bind = typeof addr === 'string' 87 | ? 'pipe ' + addr 88 | : 'port ' + addr.port; 89 | debug('Listening on ' + bind); 90 | } 91 | -------------------------------------------------------------------------------- /common/at.js: -------------------------------------------------------------------------------- 1 | /** 2 | * at用户 3 | */ 4 | 5 | const models = require('../models'); 6 | 7 | /** 8 | * 提取出@的用户名 9 | */ 10 | let fetchUsers = exports.fetchUsers = function (content) { 11 | let result = content.match(/@[a-z0-9\-_]+\b/igm); 12 | if(result) { 13 | let names = []; 14 | for(let i = 0, l = result.length; i < l; i++) { 15 | // 删除配置的 @ 符号 16 | let name = result[i].slice(1); 17 | if(names.indexOf(name) === -1) { 18 | names.push(result[i].slice(1)); 19 | } 20 | } 21 | return names; 22 | } 23 | return []; 24 | } 25 | 26 | /** 27 | * 根据内容分析出 @ 到的用户,并发送消息 28 | * 29 | * @param {any} content 30 | * @param {any} topicId 31 | * @param {any} authorId 32 | * @param {any} replyId 33 | */ 34 | exports.sendMessageToUser = async function (content, topicId, authorId, replyId) { 35 | let users = fetchUsers(content); 36 | let User = models('user'); 37 | let Message = models('message'); 38 | await Promise.all(users.map(async (username) => { 39 | let user = await User.findOneQ({ username: username }); 40 | if(user) { 41 | // 发送 at 消息 42 | await Message.createQ({ 43 | type: 'at', 44 | author_id: authorId, 45 | master_id: user._id, 46 | topic_id: topicId, 47 | reply_id: replyId, 48 | content: '@了你', 49 | }); 50 | } 51 | })) 52 | } -------------------------------------------------------------------------------- /common/page.js: -------------------------------------------------------------------------------- 1 | const config = require('../config'); 2 | /** 3 | * 计算页码 4 | * - currentPage 当前页码 5 | * - allPage 总页数 6 | * - showPageSize 显示页码数 7 | */ 8 | var getPages = exports.get = function (currentPage, allPage, showPageSize) { 9 | let step = Math.floor(showPageSize / 2); 10 | 11 | let startPage = currentPage - step; 12 | if(startPage < step) 13 | startPage = 1; 14 | else if (startPage + showPageSize > allPage) 15 | startPage = allPage - showPageSize + 1; 16 | 17 | let endPage = startPage + showPageSize - 1 > allPage 18 | ? allPage : startPage + showPageSize - 1; 19 | 20 | return { 21 | current: currentPage, 22 | start: startPage, 23 | end: endPage 24 | } 25 | } 26 | 27 | /** 28 | * 给Schema添加分页查询方法 29 | * @param {Object} schema Schema对象 30 | * @param {String} method 分页查询方法的名称 31 | */ 32 | exports.addFindPageForQuery = function (schema, method) { 33 | schema.static(method, findPageForQuery); 34 | } 35 | 36 | /** 37 | * 根据分页获取主题 38 | * Callback 39 | * - data {Array} 查询结果 40 | * - page {Object} 分页信息 开始页码、结束页码 41 | * @param {Object} query 查询匹配对象 42 | * @param {String} field 要查询的字段,为null时返回所有字段 43 | * @param {String} options 其它属性, sort skin limit 等 44 | * @param {any} current_page 要查询的页码 45 | * @param {any} pageSize 每页显示的数据条数 46 | * @param {any} showPageNum 需要在页面上显示的页码数量 47 | * @returns 48 | */ 49 | async function findPageForQuery (query, field, options, current_page, pageSize, showPageNum) { 50 | pageSize = pageSize || config.pageSize; 51 | showPageNum = showPageNum || config.showPageNum; 52 | 53 | let start_item_num = (current_page - 1) * pageSize; 54 | // 查询总条数 55 | let count = await this.countQ(query); 56 | let all_page_num = Math.ceil(count / pageSize); 57 | 58 | let pages = getPages(current_page, all_page_num, showPageNum); 59 | 60 | options = Object.assign(options, { 61 | skip: start_item_num, 62 | limit: pageSize 63 | }); 64 | 65 | let data = await this.find(query, field, options); 66 | return { 67 | data: data, 68 | page: pages 69 | } 70 | } -------------------------------------------------------------------------------- /common/storage.js: -------------------------------------------------------------------------------- 1 | 2 | const qn = require('qn'); 3 | const config = require('../config'); 4 | const fs = require('fs'); 5 | const path = require('path'); 6 | const mkdirp = require('mkdirp'); 7 | 8 | let storage; 9 | 10 | if(config.qiniu.accessKey && config.qiniu.accessKey !== 'you access key') { 11 | storage = qn.create(config.qiniu); 12 | } else { 13 | // 创建上传目录 14 | mkdirp(config.upload.path, function (err) { 15 | if(err) 16 | throw new Error('上传文件目录创建失败!'); 17 | }); 18 | 19 | storage = { 20 | upload (stream, options, cb) { 21 | let filePath = path.join(config.upload.path, options.key); 22 | 23 | stream.on('end', function () { 24 | cb(null, { 25 | key: options.key, 26 | url: filePath 27 | }); 28 | }) 29 | stream.pipe(fs.createWriteStream(filePath)); 30 | }, 31 | delete (filename, cb) { 32 | let filePath = path.join(config.upload.path, filename); 33 | fs.unlink(filePath, cb); 34 | } 35 | } 36 | } 37 | 38 | module.exports = storage; -------------------------------------------------------------------------------- /common/tools.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 通用工具库 3 | */ 4 | 5 | 6 | const validator = require('validator'); 7 | 8 | /** 9 | * 过滤查询结果中某个字段相同的数据 10 | * Callback: 11 | * 返回过滤后的数据数组 12 | * @param {Object || Array} data 查询到的数据 13 | * @param {String} key 要过滤的字段 14 | * @param {Number} count 要返回的数据数量,如果为空,则返回全部 15 | * @returns 16 | */ 17 | exports.filterDataForKey = function (data, key,count) { 18 | let temp = []; 19 | let arr = []; 20 | for(let v of data) { 21 | if(temp.indexOf(v[key].toString()) === -1) { 22 | arr.push(v); 23 | temp.push(v[key].toString()); 24 | if(count && arr.length >= count) { 25 | break; 26 | } 27 | } 28 | } 29 | return arr; 30 | } 31 | 32 | /** 33 | * 去除对象所以属性值的前后空格 34 | * 主要用户接收post数据的处理 35 | * @param {Object} obj 要处理的对象 36 | * @returns 处理过的对象 37 | */ 38 | exports.trimObjectValue = function (obj) { 39 | let temp = {}; 40 | for(let v in obj) { 41 | if(obj[v]) 42 | temp[v] = validator.trim(obj[v]); 43 | } 44 | return temp; 45 | } 46 | 47 | /** 48 | * 计算给定时间距离现在的时间 49 | * @param {Date} time 给定时间 50 | * @returns 51 | */ 52 | exports.getTimeAgo = function (time) { 53 | if(!time instanceof Date) { 54 | time = new Date(time); 55 | } 56 | let interval = Math.floor((Date.now() - time) / 1000); 57 | let temp = 0; 58 | if(interval < 60) { 59 | return interval +' 秒前'; 60 | } 61 | if((temp = interval / 60 ) < 60){ 62 | return Math.floor(temp) + ' 分钟前'; 63 | } 64 | if((temp = temp / 60 ) < 24){ 65 | return Math.floor(temp) + ' 小时前'; 66 | } 67 | if((temp = temp / 24 ) < 30){ 68 | return Math.floor(temp) + ' 天前'; 69 | } 70 | if((temp = temp / 30 ) < 12){ 71 | return Math.floor(temp) + ' 月前'; 72 | } 73 | return Math.floor(temp / 12) + ' 年前'; 74 | } 75 | -------------------------------------------------------------------------------- /common/upload.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 接受上传数据 3 | */ 4 | 5 | const Busboy = require('busboy'); 6 | const config = require('../config'); 7 | const storage = require('./storage'); 8 | const path = require('path'); 9 | 10 | 11 | module.exports = function (req, options, field) { 12 | if(arguments.length < 2) { 13 | throw new Error('upload 参数最少为2个!'); 14 | } 15 | if(typeof options === 'string') { 16 | field = options; 17 | options = {}; 18 | } 19 | 20 | options.headers = req.headers; 21 | options.limits = { fileSize: config.fileSize } 22 | const busboy = new Busboy(options); 23 | 24 | return new Promise((resolve, reject) => { 25 | let isLoaded = false; 26 | let isSaveed = false; 27 | let isError = false; 28 | let isDiscard = false; 29 | let file = null; 30 | busboy.on('finish', () => { 31 | isLoaded = true; 32 | done(); 33 | }) 34 | 35 | busboy.on('file', (fieldname, fileStream, filename, encoding, mimetype) => { 36 | // 过滤文件格式 37 | if(!/^image\/*/.test(mimetype)){ 38 | isError = false; 39 | reject(new Error('您上传的文件格式不正确,请重试')) 40 | return false; 41 | } 42 | // 检测上传文件字段是否正确 43 | if(field !== fieldname) { 44 | isDiscard = true; 45 | fileStream.resume(); 46 | return false; 47 | } 48 | 49 | fileStream.on('limit', function () { 50 | isError = true; 51 | reject(new Error('您添加的文件过大,请检查后重试!')); 52 | }); 53 | 54 | storage.upload(fileStream, {key: getFilename(fieldname,filename) }, (err, result) => { 55 | if(err) { 56 | isError = true; 57 | return reject(err); 58 | } 59 | if(isError) { 60 | // 删除已上传到的文件 61 | storage.delete(result.key, () => {}); 62 | return false; 63 | } 64 | file = { 65 | fieldname: fieldname, 66 | encoding: encoding, 67 | mimetype: mimetype, 68 | filename: result.key, 69 | url: result.url 70 | } 71 | 72 | isSaveed = true; 73 | done(); 74 | }); 75 | }) 76 | 77 | req.pipe(busboy); 78 | 79 | function done () { 80 | if(isLoaded && (isSaveed || isDiscard)) { 81 | resolve(file); 82 | } 83 | } 84 | 85 | function getFilename (fieldname, filename) { 86 | let extname = path.extname(filename); 87 | return fieldname + '-' + Date.now() + extname; 88 | } 89 | 90 | }) 91 | } -------------------------------------------------------------------------------- /config_default.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 系统配置文件 3 | */ 4 | const path = require('path'); 5 | 6 | const config = { 7 | // 本地调试模式 8 | debug: true, 9 | // 网站名称 10 | sitename: 'EasyClub', 11 | describute: "jsClub:js爱好者社区", 12 | // 板块列表 13 | tags: ['原创', '转载', '提问', '站务'], 14 | // 论坛管理员,username 15 | admins: ['dylan'], 16 | // 每页主题数量 17 | pageSize: 20, 18 | // 积分设置 19 | score: { 20 | topic: 5, // 发表一个主题 21 | reply: 1 // 一个回复 22 | }, 23 | // 显示页码数量 24 | showPageNum: 5, 25 | // 数据库连接 26 | mongodb: { 27 | user: '', 28 | pass: '', 29 | host: '127.0.0.1', 30 | port: 27017, 31 | database: 'easyclub' 32 | }, 33 | // Redis配置 34 | redis: { 35 | host: '127.0.0.1', 36 | port: 6379 37 | }, 38 | // 七牛配置信息 39 | qiniu: { 40 | bucket: 'you bucket name', 41 | accessKey: 'you access key', 42 | secretKey: 'you secret key', 43 | origin: 'http://your qiniu domain', 44 | // 如果vps在国外,请使用 http://up.qiniug.com/ ,这是七牛的国际节点 45 | // 如果在国内,此项请留空 46 | // uploadURL: 'http://xxxxxxxx', 47 | }, 48 | default_avatar: '/public/images/photo.png', // 默认头像 49 | 50 | upload: { 51 | path: path.join(__dirname, 'public/upload/'), 52 | url: '/public/upload', 53 | extnames: ['jpeg', 'jpg', 'gif', 'png'], 54 | fileSize: 1024 * 1024 55 | } 56 | } 57 | 58 | module.exports = config; -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=759670 3 | // for the documentation about the jsconfig.json format 4 | "compilerOptions": { 5 | "target": "es6", 6 | "module": "commonjs", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "exclude": [ 10 | "node_modules", 11 | "bower_components", 12 | "jspm_packages", 13 | "tmp", 14 | "temp" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /middlewares/jade_partial.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * 为jade添加 partial 局部模板方法 4 | */ 5 | 6 | const pug = require('pug'); 7 | const path = require('path'); 8 | 9 | /** 10 | * 为 pug 添加 partial 方法 11 | * 12 | * @param {String} viewPath 模板文件根目录 13 | * @returns 14 | */ 15 | module.exports = function (viewPath) { 16 | return async function (ctx, next) { 17 | if(!ctx.state.hasOwnProperty('partial')) { 18 | /** 19 | * 局部模板函数 20 | * 21 | * @param {String} pathname 要调用的局部模板路径,相对于模板文件夹 22 | * @param {Object} data 模板内部变量 23 | * @returns 24 | */ 25 | ctx.state.partial = (pathname, data) => { 26 | if(!pathname) return ; 27 | 28 | if(path.extname(pathname) != '.pug') 29 | pathname += '.pug'; 30 | 31 | if(typeof data === 'object') 32 | data = Object.assign(data, ctx.state); 33 | 34 | let fn = pug.compileFile(path.join(viewPath, pathname)); 35 | return fn(data); 36 | } 37 | } 38 | await next(); 39 | } 40 | } 41 | 42 | 43 | -------------------------------------------------------------------------------- /middlewares/return_data.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 给ctx对象添加ajax请求返回数据函数中间件 3 | */ 4 | 5 | 6 | 7 | /** 8 | * 根据请求信息和请求类型返回数据 9 | * - ctx {Object} 上下文对象 10 | * - status {Int} 0 状态码 11 | * return {Function} 12 | */ 13 | function data (ctx,status) { 14 | /** 15 | * 返回信息函数 16 | * - msg {String||Object} 返回信息说明 17 | * - obj {Object} 其它需要返回的函数 18 | */ 19 | return async (msg, obj) => { 20 | obj = obj || new Object; 21 | if(typeof msg == 'object') 22 | obj = msg; 23 | else if(typeof msg == 'string') 24 | obj.message = msg; 25 | 26 | obj.title = '提示'; 27 | obj.status = status; 28 | if(ctx.headers['x-requested-with'] === 'XMLHttpRequest') { 29 | return ctx.body = obj; 30 | } else { 31 | return await ctx.render('alert', obj); 32 | } 33 | } 34 | } 35 | 36 | module.exports = async function (ctx, next) { 37 | if(!ctx.success) 38 | // 成功 39 | ctx.success = data(ctx, 0); 40 | if(!ctx.error) 41 | // 失败 42 | ctx.error = data(ctx, 1); 43 | 44 | await next(); 45 | } -------------------------------------------------------------------------------- /middlewares/sign.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | /** 5 | * 检测是否登录 6 | */ 7 | let isLogin = exports.isLogin = async function (ctx, next) { 8 | if(!ctx.state.current_user) { 9 | return ctx.error("您还未登录,请登录后重试!", { 10 | jump: '/user/login' 11 | }); 12 | } 13 | if (ctx.state.current_user.deleted) { 14 | return ctx.error("用户已被删除!无法执行此操作", { 15 | jump: '/index' 16 | }) 17 | } 18 | await next(); 19 | } 20 | 21 | /** 22 | * 是否是管理员 23 | */ 24 | exports.isAdmin = async function isAdmin (ctx, next) { 25 | if(ctx.state.current_user && !ctx.state.current_user.deleted && ctx.state.current_user.isAdmin) { 26 | return await next(); 27 | } else { 28 | return ctx.error("您不是管理员,无法执行该操作!"); 29 | } 30 | } -------------------------------------------------------------------------------- /middlewares/stylus.js: -------------------------------------------------------------------------------- 1 | /** 2 | * stylus解析中间件 3 | * 开发中使用 4 | */ 5 | 6 | const stylus = require('stylus'); 7 | const fs = require('fs-promise'); 8 | const path = require('path'); 9 | const nib = require('nib'); 10 | 11 | 12 | module.exports = function (root, opts) { 13 | 14 | return async (ctx, next) => { 15 | if (ctx.method === 'GET' || ctx.method === 'HEAD') { 16 | let urlpath = ctx.path; 17 | if (urlpath.match(/\.styl$/)) { 18 | let content; 19 | try { 20 | content = await fs.readFile(path.join(root, urlpath), 'utf-8'); 21 | } catch (err) { 22 | ctx.status = 404; 23 | ctx.body = 'Connot find ' + ctx.url; 24 | return; 25 | } 26 | let result = stylus(content).use(nib()).render(); 27 | ctx.set('Content-Type', 'text/css; charset=UTF-8'); 28 | ctx.body = result; 29 | } else { 30 | await next(); 31 | } 32 | } else { 33 | await next(); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /models/index.js: -------------------------------------------------------------------------------- 1 | 2 | const mongoose = require('mongoose-q')(require('mongoose')); 3 | const UserSchema = require('./user'); 4 | const TopicSchema = require('./topic'); 5 | const ReplySchema = require('./reply'); 6 | const MessageSchema = require('./message'); 7 | 8 | const config = require('../config'); 9 | // 数据库 10 | require('mongoose').Promise = global.Promise 11 | 12 | let mongodb = `mongodb://${config.mongodb.host}/${config.mongodb.database}` 13 | if(config.mongodb.user) 14 | mongodb = `mongodb://${config.mongodb.user}:${config.mongodb.pass}@${config.mongodb.host}/${config.mongodb.database}` 15 | mongoose.connect(mongodb, { // MongoClient.connect(url, options, callback) 16 | useMongoClient: true, 17 | poolSize: 10 18 | }, (err) => { 19 | if(err) { 20 | console.error(err); 21 | } 22 | }); 23 | 24 | mongoose.model('user', UserSchema); 25 | mongoose.model('topic', TopicSchema); 26 | mongoose.model('reply', ReplySchema); 27 | mongoose.model('message', MessageSchema); 28 | 29 | 30 | 31 | module.exports = function (name) { 32 | name = name.toLowerCase(); 33 | return mongoose.model(name); 34 | } -------------------------------------------------------------------------------- /models/message.js: -------------------------------------------------------------------------------- 1 | 2 | const mongoose = require('mongoose'); 3 | const Schema = mongoose.Schema; 4 | const ObjectId = Schema.ObjectId; 5 | 6 | var MessageSchema = new Schema({ 7 | type: { type: String, required: true }, 8 | author_id: { type: ObjectId, required: true }, 9 | master_id: { type: ObjectId, required: true }, 10 | topic_id: { type:ObjectId}, 11 | reply_id: { type: ObjectId }, 12 | create_time: { type: Date, default: Date.now }, 13 | content: { type: String}, 14 | is_read: { type: Boolean, default: false } 15 | }); 16 | 17 | MessageSchema.index({create_time: -1}); 18 | MessageSchema.index({topic_id: 1, create_time: -1}); 19 | 20 | 21 | module.exports = MessageSchema; -------------------------------------------------------------------------------- /models/reply.js: -------------------------------------------------------------------------------- 1 | 2 | const mongoose = require('mongoose'); 3 | const Schema = mongoose.Schema; 4 | const ObjectId = Schema.ObjectId; 5 | const page = require('../common/page'); 6 | 7 | var ReplySchema = new Schema({ 8 | topic_id: { type: ObjectId, required: true }, 9 | author_id: { type: ObjectId, required: true }, 10 | create_time: { type: Date, default: Date.now }, 11 | update_time: { type: Date, default: Date.now }, 12 | content: { type: String, required: true }, 13 | deleted: { type: Boolean, default: false } 14 | }); 15 | 16 | ReplySchema.index({create_time: -1}); 17 | ReplySchema.index({topic_id: 1, create_time: -1}); 18 | 19 | 20 | page.addFindPageForQuery(ReplySchema, 'getReplyForPage'); 21 | 22 | module.exports = ReplySchema; -------------------------------------------------------------------------------- /models/topic.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 主题模型 3 | */ 4 | const mongoose = require('mongoose'); 5 | const Schema = mongoose.Schema; 6 | const ObjectId = Schema.ObjectId; 7 | const validator = require('validator'); 8 | const page = require('../common/page'); 9 | const config = require('../config'); 10 | 11 | var TopicSchema = new Schema({ 12 | title: { type: String, required: true}, 13 | content: { type: String, required: true}, 14 | author_id: { type: ObjectId, required: true }, 15 | reply_count: { type: Number, default: 0}, 16 | visit_count: { type: Number, default: 0}, 17 | last_reply: { type: ObjectId}, 18 | last_reply_at: {type: Date, default: Date.now}, 19 | tag: {type: String}, 20 | create_time: { type: Date, default: Date.now }, 21 | update_time: { type: Date, default: Date.now }, 22 | deleted: {type: Boolean, default: false}, 23 | top: {type: Boolean, default: false}, //置顶帖 24 | good: {type: Boolean, default: false} // 精华帖 25 | }); 26 | 27 | TopicSchema.index({create_time: -1}); 28 | TopicSchema.index({author_id: 1, create_time: -1}); 29 | 30 | /** 31 | * 回复主题 32 | */ 33 | TopicSchema.statics.reply = async function (topic_id, reply_id) { 34 | 35 | if(!validator.isMongoId(topic_id + '') || !validator.isMongoId(reply_id + '')) 36 | return false; 37 | 38 | let result = await this.updateQ({ 39 | _id: topic_id 40 | }, { 41 | '$inc': {'reply_count': 1}, 42 | '$set': { 43 | last_reply: reply_id, 44 | last_reply_at: Date.now() 45 | } 46 | }); 47 | return result; 48 | } 49 | 50 | /** 51 | * 获取主题信息 52 | * 并更新浏览数 53 | */ 54 | TopicSchema.statics.get_topic = async function (topic_id) { 55 | if(!validator.isMongoId(topic_id + '')) 56 | return false; 57 | let result = await this.findByIdAndUpdateQ(topic_id, { 58 | '$inc': {'visit_count': 1} 59 | }); 60 | return result; 61 | } 62 | 63 | /** 64 | * 根据分页获取主题 65 | */ 66 | page.addFindPageForQuery(TopicSchema, 'getTopicForPage'); 67 | 68 | 69 | module.exports = TopicSchema; -------------------------------------------------------------------------------- /models/user.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 用户模型 3 | */ 4 | const mongoose = require('mongoose'); 5 | const crypto = require('crypto'); 6 | const config = require('../config'); 7 | const path = require('path'); 8 | const url = require('url'); 9 | 10 | var UserSchema = new mongoose.Schema({ 11 | username: { type: String, required: true}, 12 | password: { type: String, required: true}, 13 | email: { type: String, required: true }, 14 | home: {type: String}, // 个人主页 15 | github: {type: String}, // github 16 | avatar: { type: String }, // 头像 17 | score: {type: Number, default: 0}, // 用户积分 18 | signature: {type: String, default: "无个性,不签名!"}, // 个性签名 19 | topic_count: { type: Number, default: 0 }, 20 | reply_count: { type: Number, default: 0 }, 21 | deleted: {type: Boolean, default: false}, 22 | create_time: { type: Date, default: Date.now } 23 | }); 24 | 25 | UserSchema.set('toObject', { getters: true , virtuals: true}); 26 | 27 | UserSchema.index({username: 1}, {unique: true}); 28 | 29 | UserSchema.virtual('avatar_url').get(function () { 30 | if(!this.avatar) 31 | return config.default_avatar; 32 | if(config.qiniu.origin && config.qiniu.origin !== 'http://your qiniu domain') 33 | return url.resolve(config.qiniu.origin, this.avatar); 34 | return path.join(config.upload.url, this.avatar); 35 | }) 36 | 37 | /** 38 | * password写入时加密 39 | */ 40 | UserSchema.path('password').set(function (v) { 41 | return crypto.createHash('md5').update(v).digest('base64'); 42 | }); 43 | 44 | /** 45 | * 验证用户名密码是否正确 46 | */ 47 | UserSchema.statics.check_password = async function (username, password) { 48 | let user = await this.findOneQ({ 49 | username: username, 50 | password: crypto.createHash('md5').update(password).digest('base64') 51 | }); 52 | return user; 53 | } 54 | 55 | 56 | /** 57 | * 增加减少用户文章数量 58 | */ 59 | UserSchema.statics.updateTopicCount = async function (userId, num) { 60 | let user = await this.findOneQ({_id: userId}); 61 | user.topic_count += num; 62 | // 增加减少积分 63 | user.score += num > 0 ? config.score.topic : -config.score.topic; 64 | user.save(); 65 | return user; 66 | } 67 | 68 | /** 69 | * 增加减少用户回复数量 70 | */ 71 | UserSchema.statics.updateReplyCount = async function (userId, num) { 72 | let user = await this.findOneQ({_id: userId}); 73 | user.reply_count += num; 74 | // 增加减少积分 75 | user.score += num > 0 ? config.score.reply : -config.score.reply; 76 | user.save(); 77 | return user; 78 | } 79 | 80 | 81 | 82 | 83 | module.exports = UserSchema; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "easyclub", 3 | "version": "0.1.0", 4 | "author": "dylan", 5 | "private": true, 6 | "scripts": { 7 | "start": "./node_modules/.bin/nodemon -i public/ bin/www", 8 | "koa": "node bin/www", 9 | "pm2": "NODE_ENV=production pm2 start bin/www ", 10 | "test": "make test", 11 | "build": "make build" 12 | }, 13 | "dependencies": { 14 | "busboy": "^0.2.13", 15 | "co": "^4.6.0", 16 | "debug": "^2.2.0", 17 | "koa": "^2.0.0-alpha.7", 18 | "koa-bodyparser": "^2.0.1", 19 | "koa-convert": "^1.2.0", 20 | "koa-generic-session": "^1.11.4", 21 | "koa-json": "^1.1.1", 22 | "koa-logger": "^1.3.0", 23 | "koa-onerror": "^1.2.1", 24 | "koa-pug": "^3.0.0-1", 25 | "koa-redis": "^2.1.3", 26 | "koa-router": "^7.0.0", 27 | "koa-static2": "^0.1.8", 28 | "loader": "^2.1.1", 29 | "loader-builder": "^2.4.1", 30 | "markdown-it": "^6.0.5", 31 | "mkdirp": "^0.5.1", 32 | "mongoose": "^4.9.1", 33 | "mongoose-q": "^0.1.0", 34 | "qn": "^1.3.0", 35 | "uglify-js": "^2.7.4", 36 | "validator": "^6.1.0" 37 | }, 38 | "devDependencies": { 39 | "fs-promise": "^0.5.0", 40 | "livereload": "^0.7.0", 41 | "mocha": "^3.1.2", 42 | "nib": "^1.1.0", 43 | "nodemon": "^1.8.1", 44 | "should": "^9.0.2", 45 | "stylus": "^0.54.5", 46 | "supertest": "^1.2.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /public/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k-dylan/easyclub/2680544c195a195cad5273a4c6fcaefe640d1d00/public/images/icon.png -------------------------------------------------------------------------------- /public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k-dylan/easyclub/2680544c195a195cad5273a4c6fcaefe640d1d00/public/images/logo.png -------------------------------------------------------------------------------- /public/images/photo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k-dylan/easyclub/2680544c195a195cad5273a4c6fcaefe640d1d00/public/images/photo.png -------------------------------------------------------------------------------- /public/javascripts/jquery.loadData.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ajax提交插件 3 | * by: kdylan 4 | 5 | */ 6 | ; (function ($) { 7 | function loadData (el, options) { 8 | var $el = this.$el = $(el); 9 | var $form = this.$form = $el.closest('form'); 10 | options = this.options = $.extend({ 11 | success: function () {}, 12 | before: function () {}, 13 | complete: function () {}, 14 | data: function () {} 15 | }, options); 16 | 17 | // 验证对象 18 | if(options.data && !$.isFunction(options.data)){ 19 | console.error('options.data 必须为函数,并返回要发送的对象!') 20 | } 21 | 22 | this._setOptions('method', options, 'get'); 23 | this._setOptions('action', options); 24 | this._setOptions('loading', options, '提交中……'); 25 | this.label = $el.text(); 26 | 27 | $el.on('complete', options.complete); 28 | this._bindClick(options); 29 | } 30 | 31 | loadData.prototype._setOptions = function (key, options, def) { 32 | var value = options[key] 33 | if(!value) { 34 | value = this.$el.data(key); 35 | } 36 | if(!value && this.$form) { 37 | value = this.$form.attr(key); 38 | } 39 | if(!value) { 40 | value = def; 41 | } 42 | if(value) { 43 | this[key] = value; 44 | } else { 45 | console.error(key + '不能为空!'); 46 | } 47 | } 48 | 49 | loadData.prototype._getData = function () { 50 | var data = this.options.data(); 51 | 52 | if(!data && this.$form) { 53 | data = this.$form.serialize(); 54 | } 55 | return data; 56 | } 57 | 58 | /** 59 | * 绑定单击事件 60 | */ 61 | loadData.prototype._bindClick = function (options) { 62 | var $el = this.$el 63 | var _this = this; 64 | var before = options.before; 65 | 66 | $el.click(function (e) { 67 | e.preventDefault(); 68 | if(false === before()) { 69 | return false; 70 | } 71 | 72 | var data = _this._getData(); 73 | 74 | $el.attr('disabled', true); 75 | if(_this.loading) 76 | $el.text(_this.loading); 77 | 78 | var self = this; 79 | $.ajax({ 80 | url: _this.action, 81 | type: _this.method, 82 | data: data, 83 | complete: function () { 84 | $el.attr('disabled', false).text(_this.label); 85 | $el.trigger('complete', arguments); 86 | }, 87 | success: function () { 88 | options.success.apply(self, arguments); 89 | } 90 | }) 91 | }) 92 | } 93 | 94 | $.fn.loadData = function (options) { 95 | if(!this.length) { 96 | console.warn('jQuery未选择到对象,loadData无法执行!'); 97 | return ; 98 | } 99 | this.each(function () { 100 | new loadData(this, options); 101 | }) 102 | } 103 | })(window.jQuery); 104 | 105 | 106 | -------------------------------------------------------------------------------- /public/javascripts/jquery.modal.js: -------------------------------------------------------------------------------- 1 | ; (function ($) { 2 | function Modal (el) { 3 | var mask = this.mask = $(''); 4 | var el = this.el = el; 5 | $(el).on('click', '.close', this.hide.bind(this)); 6 | } 7 | Modal.prototype._showMask = function () { 8 | this.mask.appendTo(document.body); 9 | // 强制 reflow 10 | this.mask[0].offsetHeight; 11 | this.mask.addClass('show').one('click', this.hide.bind(this)); 12 | } 13 | Modal.prototype._hideMask = function () { 14 | this.mask 15 | .removeClass('show') 16 | // 模拟transitonEnd事件,css动画完毕后执行 17 | .on('delay', function () { $(this).remove(); }) 18 | .delay(200); 19 | } 20 | 21 | $.fn.delay = function (delay) { 22 | var _this = this; 23 | setTimeout(function() { 24 | _this.trigger('delay'); 25 | }, delay); 26 | } 27 | 28 | Modal.prototype.show = function () { 29 | this._showMask(); 30 | $(this.el).show() 31 | this.el.offsetHeight; 32 | $(this.el).addClass('show'); 33 | } 34 | 35 | Modal.prototype.hide = function () { 36 | this._hideMask(); 37 | $(this.el) 38 | .removeClass('show') 39 | .on('delay', function () {$(this).hide()}) 40 | .delay(300); 41 | } 42 | 43 | $.fn.modal = function (action) { 44 | return this.each(function () { 45 | var data = $(this).data('modal'); 46 | if(!data) { 47 | data = new Modal(this); 48 | $(this).data('modal', data); 49 | } 50 | data[action](); 51 | }) 52 | } 53 | })(window.jQuery) -------------------------------------------------------------------------------- /public/javascripts/lib/editor/editor.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'icomoon'; 3 | src:url('/public/javascripts/lib/editor/fonts/icomoon.eot'); 4 | src:url('/public/javascripts/lib/editor/fonts/icomoon.eot?#iefix') format('embedded-opentype'), 5 | url('/public/javascripts/lib/editor/fonts/icomoon.woff') format('woff'), 6 | url('/public/javascripts/lib/editor/fonts/icomoon.ttf') format('truetype'), 7 | url('/public/javascripts/lib/editor/fonts/icomoon.svg#icomoon') format('svg'); 8 | font-weight: normal; 9 | font-style: normal; 10 | } 11 | 12 | /* Use the following CSS code if you want to use data attributes for inserting your icons */ 13 | [data-icon]:before { 14 | font-family: 'icomoon'; 15 | content: attr(data-icon); 16 | speak: none; 17 | font-weight: normal; 18 | font-variant: normal; 19 | text-transform: none; 20 | line-height: 1; 21 | -webkit-font-smoothing: antialiased; 22 | } 23 | 24 | /* Use the following CSS code if you want to have a class per icon */ 25 | /* 26 | Instead of a list of all class selectors, 27 | you can use the generic selector below, but it's slower: 28 | [class*="icon-"] { 29 | */ 30 | .icon-bold, .icon-italic, .icon-quote, .icon-unordered-list, .icon-ordered-list, .icon-link, .icon-image, .icon-play, .icon-music, .icon-contract, .icon-fullscreen, .icon-question, .icon-info, .icon-undo, .icon-redo, .icon-code, .icon-preview { 31 | font-family: 'icomoon'; 32 | speak: none; 33 | font-style: normal; 34 | font-weight: normal; 35 | font-variant: normal; 36 | text-transform: none; 37 | line-height: 1; 38 | -webkit-font-smoothing: antialiased; 39 | } 40 | .icon-bold:before { 41 | content: "\e000"; 42 | } 43 | .icon-italic:before { 44 | content: "\e001"; 45 | } 46 | .icon-quote:before { 47 | content: "\e003"; 48 | } 49 | .icon-unordered-list:before { 50 | content: "\e004"; 51 | } 52 | .icon-ordered-list:before { 53 | content: "\e005"; 54 | } 55 | .icon-link:before { 56 | content: "\e006"; 57 | } 58 | .icon-image:before { 59 | content: "\e007"; 60 | } 61 | .icon-play:before { 62 | content: "\e008"; 63 | } 64 | .icon-music:before { 65 | content: "\e009"; 66 | } 67 | .icon-contract:before { 68 | content: "\e00a"; 69 | } 70 | .icon-fullscreen:before { 71 | content: "\e00b"; 72 | } 73 | .icon-question:before { 74 | content: "\e00c"; 75 | } 76 | .icon-info:before { 77 | content: "\e00d"; 78 | } 79 | .icon-undo:before { 80 | content: "\e00e"; 81 | } 82 | .icon-redo:before { 83 | content: "\e00f"; 84 | } 85 | .icon-code:before { 86 | content: "\e011"; 87 | } 88 | .icon-preview:before { 89 | content: "\e002"; 90 | } 91 | /* BASICS */ 92 | 93 | .CodeMirror { 94 | height: 300px; 95 | } 96 | .CodeMirror-scroll { 97 | /* Set scrolling behaviour here */ 98 | overflow: auto; 99 | } 100 | 101 | /* PADDING */ 102 | 103 | .CodeMirror-lines { 104 | padding: 4px 0; /* Vertical padding around content */ 105 | } 106 | .CodeMirror pre { 107 | padding: 0 4px; /* Horizontal padding of content */ 108 | } 109 | 110 | .CodeMirror-scrollbar-filler { 111 | background-color: white; /* The little square between H and V scrollbars */ 112 | } 113 | 114 | /* CURSOR */ 115 | .CodeMirror div.CodeMirror-cursor { 116 | border-left: 1px solid black; 117 | z-index: 3; 118 | } 119 | /* Shown when moving in bi-directional text */ 120 | .CodeMirror div.CodeMirror-secondarycursor { 121 | border-left: 1px solid silver; 122 | } 123 | .CodeMirror.cm-keymap-fat-cursor div.CodeMirror-cursor { 124 | width: auto; 125 | border: 0; 126 | background: #7e7; 127 | z-index: 1; 128 | } 129 | /* Can style cursor different in overwrite (non-insert) mode */ 130 | .CodeMirror div.CodeMirror-cursor.CodeMirror-overwrite {} 131 | 132 | /* DEFAULT THEME */ 133 | 134 | .cm-s-paper .cm-keyword {color: #555;} 135 | .cm-s-paper .cm-atom {color: #7f8c8d;} 136 | .cm-s-paper .cm-number {color: #7f8c8d;} 137 | .cm-s-paper .cm-def {color: #00f;} 138 | .cm-s-paper .cm-variable {color: black;} 139 | .cm-s-paper .cm-variable-2 {color: #555;} 140 | .cm-s-paper .cm-variable-3 {color: #085;} 141 | .cm-s-paper .cm-property {color: black;} 142 | .cm-s-paper .cm-operator {color: black;} 143 | .cm-s-paper .cm-comment {color: #959595;} 144 | .cm-s-paper .cm-string {color: #7f8c8d;} 145 | .cm-s-paper .cm-string-2 {color: #f50;} 146 | .cm-s-paper .cm-meta {color: #555;} 147 | .cm-s-paper .cm-error {color: #f00;} 148 | .cm-s-paper .cm-qualifier {color: #555;} 149 | .cm-s-paper .cm-builtin {color: #555;} 150 | .cm-s-paper .cm-bracket {color: #997;} 151 | .cm-s-paper .cm-tag {color: #7f8c8d;} 152 | .cm-s-paper .cm-attribute {color: #7f8c8d;} 153 | .cm-s-paper .cm-header {color: #000;} 154 | .cm-s-paper .cm-quote {color: #888;} 155 | .cm-s-paper .cm-hr {color: #999;} 156 | .cm-s-paper .cm-link {color: #7f8c8d;} 157 | 158 | .cm-negative {color: #d44;} 159 | .cm-positive {color: #292;} 160 | .cm-header, .cm-strong {font-weight: bold;} 161 | .cm-em {font-style: italic;} 162 | .cm-link {text-decoration: underline;} 163 | 164 | .cm-invalidchar {color: #f00;} 165 | 166 | div.CodeMirror span.CodeMirror-matchingbracket {color: #0f0;} 167 | div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #f22;} 168 | 169 | 170 | /* STOP */ 171 | 172 | /* The rest of this file contains styles related to the mechanics of 173 | the editor. You probably shouldn't touch them. */ 174 | 175 | .CodeMirror { 176 | position: relative; 177 | overflow: hidden; 178 | } 179 | 180 | .CodeMirror-scroll { 181 | /* 30px is the magic margin used to hide the element's real scrollbars */ 182 | /* See overflow: hidden in .CodeMirror, and the paddings in .CodeMirror-sizer */ 183 | margin-bottom: -30px; margin-right: -30px; 184 | padding-bottom: 30px; padding-right: 30px; 185 | height: 100%; 186 | outline: none; /* Prevent dragging from highlighting the element */ 187 | position: relative; 188 | } 189 | .CodeMirror-sizer { 190 | position: relative; 191 | } 192 | 193 | /* The fake, visible scrollbars. Used to force redraw during scrolling 194 | before actuall scrolling happens, thus preventing shaking and 195 | flickering artifacts. */ 196 | .CodeMirror-vscrollbar, .CodeMirror-hscrollbar, .CodeMirror-scrollbar-filler { 197 | position: absolute; 198 | z-index: 6; 199 | display: none; 200 | } 201 | .CodeMirror-vscrollbar { 202 | right: 0; top: 0; 203 | overflow-x: hidden; 204 | overflow-y: scroll; 205 | } 206 | .CodeMirror-hscrollbar { 207 | bottom: 0; left: 0; 208 | overflow-y: hidden; 209 | overflow-x: scroll; 210 | } 211 | .CodeMirror-scrollbar-filler { 212 | right: 0; bottom: 0; 213 | z-index: 6; 214 | } 215 | 216 | .CodeMirror-lines { 217 | cursor: text; 218 | } 219 | .CodeMirror pre { 220 | /* Reset some styles that the rest of the page might have set */ 221 | -moz-border-radius: 0; -webkit-border-radius: 0; -o-border-radius: 0; border-radius: 0; 222 | border-width: 0; 223 | background: transparent; 224 | font-family: inherit; 225 | font-size: inherit; 226 | margin: 0; 227 | white-space: pre-wrap; 228 | word-wrap: normal; 229 | line-height: inherit; 230 | color: inherit; 231 | z-index: 2; 232 | position: relative; 233 | overflow: visible; 234 | } 235 | .CodeMirror-wrap pre { 236 | word-wrap: break-word; 237 | white-space: pre-wrap; 238 | word-break: normal; 239 | } 240 | .CodeMirror-linebackground { 241 | position: absolute; 242 | left: 0; right: 0; top: 0; bottom: 0; 243 | z-index: 0; 244 | } 245 | 246 | .CodeMirror-linewidget { 247 | position: relative; 248 | z-index: 2; 249 | overflow: auto; 250 | } 251 | 252 | .CodeMirror-widget { 253 | display: inline-block; 254 | } 255 | 256 | .CodeMirror-wrap .CodeMirror-scroll { 257 | overflow-x: hidden; 258 | } 259 | 260 | .CodeMirror-measure { 261 | position: absolute; 262 | width: 100%; height: 0px; 263 | overflow: hidden; 264 | visibility: hidden; 265 | } 266 | .CodeMirror-measure pre { position: static; } 267 | 268 | .CodeMirror div.CodeMirror-cursor { 269 | position: absolute; 270 | visibility: hidden; 271 | border-right: none; 272 | width: 0; 273 | } 274 | .CodeMirror-focused div.CodeMirror-cursor { 275 | visibility: visible; 276 | } 277 | 278 | .CodeMirror-selected { background: #d9d9d9; } 279 | .CodeMirror-focused .CodeMirror-selected { background: #BDC3C7; } 280 | 281 | .cm-searching { 282 | background: #ffa; 283 | background: rgba(255, 255, 0, .4); 284 | } 285 | 286 | /* IE7 hack to prevent it from returning funny offsetTops on the spans */ 287 | .CodeMirror span { *vertical-align: text-bottom; } 288 | 289 | @media print { 290 | /* Hide the cursor when printing */ 291 | .CodeMirror div.CodeMirror-cursor { 292 | visibility: hidden; 293 | } 294 | } 295 | .CodeMirror { 296 | height: 450px; 297 | } 298 | :-webkit-full-screen { 299 | background: #f9f9f5; 300 | padding: 0.5em 1em; 301 | width: 100%; 302 | height: 100%; 303 | } 304 | :-moz-full-screen { 305 | padding: 0.5em 1em; 306 | background: #f9f9f5; 307 | width: 100%; 308 | height: 100%; 309 | } 310 | .editor-wrapper { 311 | font: 16px/1.62 "Helvetica Neue", "Xin Gothic", "Hiragino Sans GB", "WenQuanYi Micro Hei", "Microsoft YaHei", sans-serif; 312 | color: #2c3e50; 313 | } 314 | /* this is the title */ 315 | .editor-wrapper input.title { 316 | font: 18px "Helvetica Neue", "Xin Gothic", "Hiragino Sans GB", "WenQuanYi Micro Hei", "Microsoft YaHei", sans-serif; 317 | background: transparent; 318 | padding: 4px; 319 | width: 100%; 320 | border: none; 321 | outline: none; 322 | opacity: 0.6; 323 | } 324 | .editor-toolbar { 325 | position: relative; 326 | opacity: 0.6; 327 | -webkit-user-select: none; 328 | -moz-user-select: none; 329 | -ms-user-select: none; 330 | -o-user-select: none; 331 | user-select: none; 332 | } 333 | .editor-toolbar:before, .editor-toolbar:after { 334 | display: block; 335 | content: ' '; 336 | height: 1px; 337 | background-color: #bdc3c7; 338 | background: -moz-linear-gradient(45deg, #f9f9f9, #bdc3c7, #f9f9f9); 339 | background: -webkit-linear-gradient(45deg, #f9f9f9, #bdc3c7, #f9f9f9); 340 | background: -ms-linear-gradient(45deg, #f9f9f9, #bdc3c7, #f9f9f9); 341 | background: linear-gradient(45deg, #f9f9f9, #bdc3c7, #f9f9f9); 342 | } 343 | .editor-toolbar:before { 344 | margin-bottom: 8px; 345 | } 346 | .editor-toolbar:after { 347 | margin-top: 8px; 348 | } 349 | .editor-wrapper input.title:hover, .editor-wrapper input.title:focus, .editor-toolbar:hover { 350 | opacity: 0.8; 351 | } 352 | .editor-toolbar a { 353 | display: inline-block; 354 | text-align: center; 355 | text-decoration: none !important; 356 | color: #2c3e50 !important; 357 | width: 24px; 358 | height: 24px; 359 | margin: 2px; 360 | border: 1px solid transparent; 361 | border-radius: 3px; 362 | cursor: pointer; 363 | } 364 | .editor-toolbar a:hover, .editor-toolbar a.active { 365 | background: #fcfcfc; 366 | border-color: #95a5a6; 367 | } 368 | .editor-toolbar a:before { 369 | line-height: 24px; 370 | } 371 | .editor-toolbar i.separator { 372 | display: inline-block; 373 | width: 0; 374 | border-left: 1px solid #d9d9d9; 375 | border-right: 1px solid white; 376 | color: transparent; 377 | text-indent: -10px; 378 | margin: 0 6px; 379 | } 380 | .editor-toolbar a.icon-fullscreen { 381 | position: absolute; 382 | right: 0; 383 | } 384 | .editor-statusbar { 385 | border-top: 1px solid #ece9e9; 386 | padding: 8px 10px; 387 | font-size: 12px; 388 | color: #959694; 389 | text-align: right; 390 | } 391 | .editor-statusbar span { 392 | display: inline-block; 393 | min-width: 4em; 394 | margin-left: 1em; 395 | } 396 | .editor-statusbar .lines:before { 397 | content: 'lines: '; 398 | } 399 | .editor-statusbar .words:before { 400 | content: 'words: '; 401 | } 402 | .editor-preview { 403 | position: absolute; 404 | width: 100%; 405 | height: 100%; 406 | top: 0; 407 | left: 100%; 408 | background: #f9f9f5; 409 | z-index: 9999; 410 | overflow: auto; 411 | -webkit-transition: left 0.2s ease; 412 | -moz-transition: left 0.2s ease; 413 | -ms-transition: left 0.2s ease; 414 | transition: left 0.2s ease; 415 | } 416 | .editor-preview-active { 417 | left: 0; 418 | } 419 | .editor-preview > p { 420 | margin-top: 0; 421 | } 422 | -------------------------------------------------------------------------------- /public/javascripts/lib/editor/ext.js: -------------------------------------------------------------------------------- 1 | (function(Editor, marked, WebUploader){ 2 | // Set default options 3 | var md = marked(); 4 | 5 | md.set({ 6 | html: false, // Enable HTML tags in source 7 | xhtmlOut: false, // Use '/' to close single tags (
) 8 | breaks: true, // Convert '\n' in paragraphs into
9 | langPrefix: 'language-', // CSS language prefix for fenced blocks 10 | linkify: false, // Autoconvert URL-like text to links 11 | typographer: false, // Enable smartypants and other sweet transforms 12 | }); 13 | 14 | window.markdowniter = md; 15 | 16 | var toolbar = Editor.toolbar; 17 | 18 | var replaceTool = function(name, callback){ 19 | for(var i=0, len=toolbar.length; i