├── run.py ├── .gitignore ├── app ├── static │ ├── css │ │ ├── browse-comments.css │ │ ├── svgicons.css │ │ ├── browse.css │ │ ├── index.css │ │ ├── reset.css │ │ ├── components │ │ │ ├── user-dashboard.css │ │ │ ├── banner.css │ │ │ ├── browse-sidebar.css │ │ │ ├── notification.css │ │ │ ├── kifu-list.css │ │ │ ├── comment-list.css │ │ │ └── login-signup.css │ │ ├── base.css │ │ ├── kifu │ │ │ ├── kifu.css │ │ │ ├── comment.css │ │ │ ├── action-bar.css │ │ │ ├── info-panel.css │ │ │ └── nav_edit.css │ │ └── upload.css │ └── js │ │ ├── exceptions.js │ │ ├── thumbnail.js │ │ ├── new.js │ │ ├── node.js │ │ ├── utils.js │ │ ├── notification.js │ │ ├── config.js │ │ ├── app.js │ │ ├── constants.js │ │ ├── browse.js │ │ ├── upload.js │ │ ├── board_canvas.js │ │ ├── board.js │ │ ├── gametree.js │ │ ├── sgf.js │ │ ├── driver.js │ │ └── controller.js ├── templates │ ├── kifu │ │ ├── kifu-comment.html │ │ ├── kifu-edit.html │ │ ├── kifu-info-panel.html │ │ ├── kifu-navigation.html │ │ ├── kifu.html │ │ └── kifu-action-bar.html │ ├── browse-comments.html │ ├── components │ │ ├── user-dashboard.html │ │ ├── browse-sidebar.html │ │ ├── banner.html │ │ ├── notification.html │ │ ├── kifu-list.html │ │ ├── comment-list.html │ │ └── login-signup.html │ ├── browse.html │ ├── index.html │ ├── layout.html │ ├── upload.html │ └── macros.html ├── __init__.py ├── forms.py ├── models.py ├── sgf.py └── views.py ├── config.py ├── requirement.txt ├── script.py └── LICENSE /run.py: -------------------------------------------------------------------------------- 1 | from app import views, forms, app 2 | 3 | app.run() 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv/ 2 | __pycache__/ 3 | instance/ 4 | *.py[cod] 5 | app/static/assets/ 6 | -------------------------------------------------------------------------------- /app/static/css/browse-comments.css: -------------------------------------------------------------------------------- 1 | .container { 2 | font-size: 0; 3 | position: absolute; 4 | top: 60px; 5 | bottom: 22px; 6 | left: 0; 7 | right: 0; 8 | height: 780px; 9 | width: 990px; 10 | margin: auto; 11 | } 12 | -------------------------------------------------------------------------------- /app/templates/kifu/kifu-comment.html: -------------------------------------------------------------------------------- 1 | 5 |
6 | 7 | 8 |
9 | -------------------------------------------------------------------------------- /app/static/css/svgicons.css: -------------------------------------------------------------------------------- 1 | /* ----- 2 | SVG Icons - svgicons.sparkk.fr 3 | ----- */ 4 | 5 | .svg-icon { 6 | width: 1em; 7 | height: 1em; 8 | } 9 | 10 | .svg-icon path, 11 | .svg-icon polygon, 12 | .svg-icon rect { 13 | fill: #e8e8e8; 14 | } 15 | 16 | .svg-icon circle { 17 | stroke: #e8e8e8; 18 | stroke-width: 1; 19 | } 20 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | DEBUG = True 2 | SQLALCHEMY_TRACK_MODIFICATIONS = False 3 | BCRYPT_LOG_ROUNDS = 15 4 | SGF_FOLDER = '/home/guyu/kifutalk/app/static/assets/sgf' 5 | THUMBNAIL_FOLDER = '/home/guyu/kifutalk/app/static/assets/thumbnail' 6 | EMPTY_BOARD_DATAURL = '/home/guyu/kifutalk/app/static/assets/empty_board.dataurl' 7 | KIFU_PERPAGE = 5 8 | COMMENT_PERPAGE = 6 9 | THUMBNAIL_SIZE = (512, 512) 10 | URL_TIMEOUT = 10 # seconds 11 | -------------------------------------------------------------------------------- /app/static/css/browse.css: -------------------------------------------------------------------------------- 1 | .container { 2 | font-size: 0; 3 | position: absolute; 4 | top: 60px; 5 | bottom: 22px; 6 | left: 0; 7 | right: 0; 8 | height: 772px; 9 | width: 1260px; 10 | margin: auto; 11 | } 12 | 13 | .browse-sidebar { 14 | display: inline-block; 15 | vertical-align: top; 16 | margin-right: 30px; 17 | } 18 | 19 | .list-container { 20 | display: inline-block; 21 | vertical-align: top; 22 | } 23 | -------------------------------------------------------------------------------- /requirement.txt: -------------------------------------------------------------------------------- 1 | appdirs==1.4.3 2 | bcrypt==3.1.3 3 | blinker==1.4 4 | cffi==1.10.0 5 | click==6.7 6 | Flask==0.12 7 | Flask-Bcrypt==0.7.1 8 | Flask-Login==0.4.0 9 | Flask-Mail==0.9.1 10 | Flask-SQLAlchemy==2.2 11 | Flask-WTF==0.14.2 12 | itsdangerous==0.24 13 | Jinja2==2.9.5 14 | MarkupSafe==1.0 15 | packaging==16.8 16 | pycparser==2.17 17 | pyparsing==2.2.0 18 | six==1.10.0 19 | SQLAlchemy==1.1.7 20 | Werkzeug==0.12.1 21 | WTForms==2.1 22 | -------------------------------------------------------------------------------- /app/static/css/index.css: -------------------------------------------------------------------------------- 1 | .container { 2 | font-size: 0; 3 | text-align: center; 4 | height: 700px; 5 | width: 1450px; 6 | position: absolute; 7 | top: 60px; 8 | bottom: 22px; 9 | left: 0; 10 | right: 0; 11 | margin: auto; 12 | } 13 | 14 | .banner { 15 | display: inline-block; 16 | vertical-align: top; 17 | margin-right: 20px; 18 | } 19 | 20 | .login-signup, .user-dashboard { 21 | margin-left: 20px; 22 | vertical-align: top; 23 | display: inline-block; 24 | } 25 | -------------------------------------------------------------------------------- /app/static/js/exceptions.js: -------------------------------------------------------------------------------- 1 | var exceptions = (function() { 2 | var createCustomException = function(name) { 3 | return function(code, message) { 4 | this.prototype = new Error(); 5 | this.prototype.constructor = name; 6 | this.code = code; 7 | this.message = message; 8 | } 9 | }; 10 | 11 | return { 12 | ParsingError: createCustomException('ParsingError'), 13 | UploadError: createCustomException('UploadError'), 14 | NetworkError: createCustomException('NetworkError') 15 | }; 16 | }()); 17 | -------------------------------------------------------------------------------- /script.py: -------------------------------------------------------------------------------- 1 | from app import db 2 | from app.models import Rank 3 | 4 | SQL_CMD = 'CREATE DATABASE kifutalk CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci' 5 | 6 | # initialize database 7 | db.create_all() 8 | 9 | # populate ranks table 10 | for i in range(18): 11 | r = Rank(rank_en='%dk'%(18-i), rank_cn='%d级'%(18-i)) 12 | db.session.add(r) 13 | for i in range(9): 14 | r = Rank(rank_en='%dd'%(i+1), rank_cn='%d段'%(i+1)) 15 | db.session.add(r) 16 | for i in range(9): 17 | r = Rank(rank_en='%dp'%(i+1), rank_cn='职业%d段'%(i+1)) 18 | db.session.add(r) 19 | db.session.commit() 20 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask_sqlalchemy import SQLAlchemy 3 | from flask_bcrypt import Bcrypt 4 | from flask_login import LoginManager 5 | 6 | app = Flask(__name__, instance_relative_config=True) 7 | app.config.from_object('config') 8 | app.config.from_pyfile('config.py') 9 | 10 | db = SQLAlchemy(app) 11 | bcrypt = Bcrypt(app) 12 | login_manager = LoginManager() 13 | login_manager.init_app(app) 14 | 15 | from . import models 16 | 17 | @login_manager.user_loader 18 | def load_user(userid): 19 | return models.User.query.get(int(userid)) 20 | 21 | if __name__ == "__main__": 22 | app.run() 23 | -------------------------------------------------------------------------------- /app/static/js/thumbnail.js: -------------------------------------------------------------------------------- 1 | var createThumbnail = function(sgfStr, quality) { 2 | var canvas = document.createElement('canvas'); 3 | var driver = new Driver(sgfStr); 4 | // navigate to the end of kifu 5 | while (!driver.gameTree.atEnd()) { 6 | if (!driver.next()) { 7 | break; 8 | } 9 | } 10 | // render without markers/indicators and export as base64 image 11 | var bc = new BoardCanvas(canvas, canvas.getContext('2d'), driver); 12 | bc.simpleRender(); 13 | var img = bc.canvas.toDataURL('image/jpeg', quality); 14 | // strip img of dataURL prefix 15 | return img.replace(/^data:image\/\w+;base64,/, ''); 16 | } 17 | -------------------------------------------------------------------------------- /app/static/js/new.js: -------------------------------------------------------------------------------- 1 | var newKifu = document.getElementById('new'); 2 | 3 | newKifu.addEventListener('click', function(e) { 4 | var xhr = new XMLHttpRequest(); 5 | // redirect to url that server provides 6 | // either login page or kifu page 7 | xhr.addEventListener('readystatechange', function(e) { 8 | if (xhr.readyState === 4 && xhr.status === 200) { 9 | window.location.replace(JSON.parse(xhr.responseText).redirect); 10 | } 11 | }); 12 | var url = '/new'; 13 | var data = JSON.stringify({ 14 | 'img': createThumbnail('()', config.tq) 15 | }); 16 | xhr.open('POST', url); 17 | xhr.setRequestHeader('Content-type', 'application/json'); 18 | xhr.send(data); 19 | }); 20 | -------------------------------------------------------------------------------- /app/templates/browse-comments.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {% block title %}Kifutalk - View Comments{% endblock %} 4 | {% block css %} 5 | {{ super() }} 6 | 7 | 8 | {% endblock %} 9 | {% block body %} 10 |
11 | {% with 12 | prev_url=url_for('browse_comment', user_id=uid, page=page_num-1), 13 | next_url=url_for('browse_comment', user_id=uid, page=page_num+1) 14 | %} 15 | {% include('components/comment-list.html') %} 16 | {% endwith %} 17 |
18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /app/templates/components/user-dashboard.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | Hello, {{ current_user.username }} 5 | ({{ current_user.rank }}) 6 |

7 | Log out 8 |
9 |
10 | View My Uploads 11 | View Saved Kifus 12 | View My Comments 13 |
14 |
15 | -------------------------------------------------------------------------------- /app/templates/components/browse-sidebar.html: -------------------------------------------------------------------------------- 1 |
2 |

{{ browse_title }}

3 | 8 | 16 | 21 | 22 |
23 | -------------------------------------------------------------------------------- /app/templates/components/banner.html: -------------------------------------------------------------------------------- 1 | 21 | -------------------------------------------------------------------------------- /app/static/js/node.js: -------------------------------------------------------------------------------- 1 | var Node = function(parent) { 2 | // id used to identify the node in a game tree 3 | // -1 is placeholder value 4 | // first node in the tree should start with id number 0 5 | this.id = -1 6 | 7 | this.parent = parent; 8 | this.children = []; 9 | 10 | // an action is a prop-value pair 11 | // representation in sgf: P[V] 12 | // representation in javascript: {prop: P: value: V} 13 | this.actions = []; 14 | }; 15 | 16 | Node.prototype.addChild = function(child) { 17 | this.children.push(child); 18 | }; 19 | 20 | Node.prototype.addAction = function(prop, val) { 21 | this.actions.push({ 22 | 'prop': prop, 23 | 'value': val 24 | }); 25 | }; 26 | 27 | // return all children of the node (including itself) 28 | Node.prototype.getChildren = function() { 29 | var children = []; 30 | 31 | // dfs to get all children (including the node itself) 32 | var dfs = function(root) { 33 | children.push(root); 34 | root.children.forEach(function(child) { 35 | dfs(child); 36 | }); 37 | } 38 | 39 | dfs(this); 40 | return children; 41 | } 42 | -------------------------------------------------------------------------------- /app/static/js/utils.js: -------------------------------------------------------------------------------- 1 | var utils = (function() { 2 | // convert letter coordinate to number 3 | var l2n = function(ch) { 4 | // 'a' has ASCII code 97 5 | return ch.toLowerCase().charCodeAt(0) - 97; 6 | } 7 | 8 | // convert number coordinate to letter 9 | var n2l = function(n) { 10 | return String.fromCharCode(n + 97); 11 | } 12 | 13 | // convert board coordinates to canvas coordinates 14 | var b2c = function(i, spacing, lineWidth) { 15 | return (i + 1) * spacing + i * lineWidth; 16 | } 17 | 18 | // convert canvas cordinates to board coordinates 19 | var c2b = function(x, spacing, lineWidth) { 20 | var offset = (x - spacing) % (spacing + lineWidth); 21 | var bc = Math.floor((x - spacing) / (spacing + lineWidth)); 22 | var error = config.canvas.er * spacing; 23 | if (offset < error) { 24 | return bc; 25 | } else if (spacing - offset < error) { 26 | return bc + 1; 27 | } else { 28 | return -1; 29 | } 30 | } 31 | 32 | return { 33 | l2n: l2n, 34 | n2l: n2l, 35 | b2c: b2c, 36 | c2b: c2b 37 | }; 38 | })(); 39 | -------------------------------------------------------------------------------- /app/static/js/notification.js: -------------------------------------------------------------------------------- 1 | var notificationHeader = document.querySelector('.notification .header'); 2 | var notificationUL = document.querySelector('.notification .list'); 3 | 4 | var markAsRead = function(notificationID) { 5 | var xhr = new XMLHttpRequest(); 6 | xhr.open('POST', '/read-notification/' + notificationID); 7 | xhr.send(); 8 | } 9 | 10 | if (notificationHeader) { 11 | notificationHeader.addEventListener('click', function(e) { 12 | if (notificationUL.children.length === 0) { 13 | return; 14 | } 15 | if (notificationUL.style.display === 'none') { 16 | notificationUL.style.display = 'block'; 17 | notificationHeader.style.background = '#254977'; 18 | } else { 19 | notificationUL.style.display = 'none'; 20 | notificationHeader.style.background = '#355987'; 21 | } 22 | }); 23 | 24 | for (var i = 0; i < notificationUL.children.length; i++) { 25 | var notificationAnchor = notificationUL.children[i]; 26 | notificationAnchor.addEventListener('click', function(e) { 27 | markAsRead(this.getAttribute('nid')); 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Guyu Fan 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 | -------------------------------------------------------------------------------- /app/templates/browse.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% from 'macros.html' import render_field %} 3 | 4 | {% block title %}Kifutalk - View Kifus{% endblock %} 5 | {% block css %} 6 | {{ super() }} 7 | 8 | 9 | 10 | {% endblock %} 11 | {% block body %} 12 |
13 | {% include('components/browse-sidebar.html') %} 14 | {% with 15 | prev_url=url_for('browse_kifu', page=page_num-1), 16 | next_url=url_for('browse_kifu', page=page_num+1) 17 | %} 18 | {% include('components/kifu-list.html') %} 19 | {% endwith %} 20 |
21 | {% endblock %} 22 | {% block script %} 23 | 27 | 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /app/static/js/config.js: -------------------------------------------------------------------------------- 1 | var config = (function() { 2 | // board dimension 3 | var board_size = 19; 4 | 5 | // color palette 6 | var colors = { 7 | 'bg': '#F1C070', 8 | 'line': '#623F16', 9 | 'star': '#623F16', 10 | 'b': '#464646', 11 | 'w': '#F8F8F8', 12 | 'n': '#D44942', 13 | 'mk': { 14 | '.': '#464646', 15 | 'b': '#F8F8F8', 16 | 'w': '#464646' 17 | } 18 | }; 19 | 20 | // canvas appearance configuration 21 | var canvas = { 22 | 'lw': 1, // line width 23 | 'sp': 60, // spacing relative to line width 24 | 'px': 600, // scale to how many pixels 25 | 'er': 0.45, // click error range (see utils.c2b) 26 | 'st': 0.5, // stone radius relative to spacing 27 | 'mk': 0.3, // stone marker radius relative to spacing 28 | 'mw': 2.5, // stone marker width relative to line width 29 | 'sr': 0.1, // star point radius relative to spacing 30 | 'nx': 0.32 // next move marker radius relative to spacing 31 | }; 32 | 33 | // thumbnail quality 34 | var thumbnail_quality = 0.8; 35 | 36 | return { 37 | sz: board_size, 38 | colors: colors, 39 | canvas: canvas, 40 | tq: thumbnail_quality 41 | }; 42 | })(); 43 | -------------------------------------------------------------------------------- /app/static/js/app.js: -------------------------------------------------------------------------------- 1 | var driver = new Driver(kifu.sgf); 2 | 3 | if (nodeID) { 4 | try { 5 | driver.navigateTo(parseInt(nodeID)); 6 | } catch (e) { 7 | console.error(e); 8 | } 9 | } 10 | 11 | var bc = new BoardCanvas( 12 | document.getElementById('board'), 13 | document.getElementById('board').getContext('2d'), 14 | driver 15 | ); 16 | 17 | var controller = new Controller( 18 | kifu, 19 | kifuComments, 20 | bc, 21 | document.getElementById('control') 22 | ); 23 | 24 | // if commentID is also available (nodeID must be available too) 25 | // scroll that comment into view 26 | if (nodeID && commentID) { 27 | try { 28 | // find the comment element 29 | var commentElements = controller.html.commentList.childNodes; 30 | var commentElement = null; 31 | for (var i = 0; i < commentElements.length; i++) { 32 | if (commentElements[i].getAttribute('comment-id') === commentID) { 33 | commentElement = commentElements[i]; 34 | commentElement.classList.add('highlight'); 35 | break; 36 | } 37 | } 38 | if (commentElement) { 39 | commentElement.scrollIntoView(); 40 | } 41 | } catch (e) { 42 | console.error(e); 43 | } 44 | } 45 | 46 | if (edit === 'True') { 47 | controller.html.toggleEdit.click(); 48 | } 49 | -------------------------------------------------------------------------------- /app/templates/components/notification.html: -------------------------------------------------------------------------------- 1 | {% with uns = current_user.unread_notifications %} 2 |
3 |

4 | {% if uns|length == 0 %} 5 | {{ current_user.username }}, you have no new notifications 6 | {% elif uns|length == 1 %} 7 | {{ current_user.username }}, you have 1 notification 8 | {% else %} 9 | {{ current_user.username }}, you have 10 | {{ uns|length }} 11 | notifications 12 | {% endif %} 13 |

14 | 15 | 33 |
34 | {% endwith %} 35 | -------------------------------------------------------------------------------- /app/static/css/reset.css: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, body, div, span, applet, object, iframe, 7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 8 | a, abbr, acronym, address, big, cite, code, 9 | del, dfn, em, img, ins, kbd, q, s, samp, 10 | small, strike, strong, sub, sup, tt, var, 11 | b, u, i, center, 12 | dl, dt, dd, ol, ul, li, 13 | fieldset, form, label, legend, 14 | table, caption, tbody, tfoot, thead, tr, th, td, 15 | article, aside, canvas, details, embed, 16 | figure, figcaption, footer, header, hgroup, 17 | menu, nav, output, ruby, section, summary, 18 | time, mark, audio, video { 19 | margin: 0; 20 | padding: 0; 21 | border: 0; 22 | font-size: 100%; 23 | font: inherit; 24 | vertical-align: baseline; 25 | } 26 | /* HTML5 display-role reset for older browsers */ 27 | article, aside, details, figcaption, figure, 28 | footer, header, hgroup, menu, nav, section { 29 | display: block; 30 | } 31 | body { 32 | line-height: 1; 33 | } 34 | ol, ul { 35 | list-style: none; 36 | } 37 | blockquote, q { 38 | quotes: none; 39 | } 40 | blockquote:before, blockquote:after, 41 | q:before, q:after { 42 | content: ''; 43 | content: none; 44 | } 45 | table { 46 | border-collapse: collapse; 47 | border-spacing: 0; 48 | } 49 | -------------------------------------------------------------------------------- /app/static/css/components/user-dashboard.css: -------------------------------------------------------------------------------- 1 | .user-dashboard { 2 | width: 400px; 3 | color: #f8f8f8; 4 | font-family: 'Open Sans', sans-serif; 5 | } 6 | 7 | .greeting { 8 | background: #355987; 9 | text-align: center; 10 | } 11 | 12 | .greeting p { 13 | padding: 60px 50px; 14 | font-size: 25px; 15 | letter-spacing: 0.5px; 16 | font-weight: bold; 17 | } 18 | 19 | .greeting p span { 20 | height: 40px; 21 | line-height: 40px; 22 | display: inline-block; 23 | overflow: hidden; 24 | } 25 | 26 | .greeting p .username { 27 | max-width: 230px; 28 | white-space: nowrap; 29 | text-overflow: ellipsis; 30 | overflow: hidden; 31 | } 32 | 33 | .greeting a { 34 | display: block; 35 | color: #e8e8e8; 36 | font-size: 20px; 37 | line-height: 23px; 38 | height: 23px; 39 | text-decoration: none; 40 | border-top: 1px solid #254977; 41 | padding: 20px; 42 | } 43 | 44 | .greeting a:hover { 45 | background: #153967; 46 | } 47 | 48 | .action { 49 | width: 400px; 50 | text-align: center; 51 | padding: 40px 0; 52 | height: 53px; 53 | line-height: 53px; 54 | display: block; 55 | font-size: 25px; 56 | font-weight: bold; 57 | letter-spacing: 0.8px; 58 | color: #f8f8f8; 59 | background: #254977; 60 | text-decoration: none; 61 | } 62 | 63 | #view-saved { 64 | border-top: 1px solid #153967; 65 | border-bottom: 1px solid #153967; 66 | height: 48px; 67 | } 68 | 69 | .action:hover { 70 | background: #153967; 71 | } 72 | -------------------------------------------------------------------------------- /app/static/css/components/banner.css: -------------------------------------------------------------------------------- 1 | .poster { 2 | font-family: 'Open Sans', sans-serif; 3 | height: 220px; 4 | width: 900px; 5 | background: #355987; 6 | color: #f8f8f8; 7 | } 8 | 9 | .poster h2 { 10 | padding-top: 30px; 11 | height: 100px; 12 | line-height: 100px; 13 | text-align: center; 14 | font-size: 60px; 15 | letter-spacing: 1.5px; 16 | font-weight: bold; 17 | } 18 | 19 | .poster h3 { 20 | height: 50px; 21 | line-height: 50px; 22 | text-align: center; 23 | font-size: 22px; 24 | letter-spacing: 1px; 25 | } 26 | 27 | .features { 28 | font-size: 0; 29 | } 30 | 31 | .feature { 32 | display: inline-block; 33 | vertical-align: top; 34 | width: 300px; 35 | height: 400px; 36 | background: #254977; 37 | color: #f8f8f8; 38 | font-family: 'Open Sans', sans-serif; 39 | font-size: 20px; 40 | text-decoration: none; 41 | } 42 | 43 | .feature:hover { 44 | background: #153967; 45 | } 46 | 47 | .feature h4 { 48 | height: 80px; 49 | line-height: 80px; 50 | text-align: center; 51 | font-size: 35px; 52 | font-weight: bold; 53 | letter-spacing: 1px; 54 | } 55 | 56 | .feature p { 57 | text-align: left; 58 | font-family: 'Open Sans', sans-serif; 59 | font-size: 18px; 60 | font-weight: 600; 61 | letter-spacing: 0.5px; 62 | line-height: 35px; 63 | width: 230px; 64 | height: 280px; 65 | padding: 0 35px 25px 35px; 66 | } 67 | 68 | #upload { 69 | border-left: 1px solid #153967; 70 | border-right: 1px solid #153967; 71 | width: 298px; 72 | } 73 | -------------------------------------------------------------------------------- /app/static/css/base.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #51739E; 3 | font-family: 'Open Sans', sans-serif; 4 | } 5 | 6 | header { 7 | position: fixed; 8 | left: 0; 9 | right: 0; 10 | top: 0; 11 | z-index: 10; 12 | height: 60px; 13 | color: #97afcf; 14 | background: #355987; 15 | } 16 | 17 | header h1 a { 18 | font-size: 30px; 19 | line-height: 60px; 20 | padding: 0 20px; 21 | display: block; 22 | float: left; 23 | font-family: 'Patua One', cursive; 24 | font-weight: 100; 25 | letter-spacing: 2.5px; 26 | text-decoration: none; 27 | color: inherit; 28 | } 29 | 30 | header h1 a:hover { 31 | background: #254977; 32 | } 33 | 34 | header nav { 35 | display: block; 36 | float: right; 37 | } 38 | 39 | footer { 40 | height: 12px; 41 | position: fixed; 42 | left: 0; 43 | right: 0; 44 | bottom: 0; 45 | background: #355987; 46 | padding: 5px; 47 | clear: both; 48 | } 49 | 50 | footer p { 51 | font-size: 12px; 52 | line-height: 12px; 53 | text-align: center; 54 | color: #97afcf; 55 | } 56 | 57 | footer p a { 58 | color: #97afcf; 59 | } 60 | footer p a:visited { 61 | color: #97afcf; 62 | } 63 | 64 | .flashes { 65 | position: fixed; 66 | top: 60px; 67 | left: 0; 68 | right: 0; 69 | z-index: 1; 70 | } 71 | 72 | .flashes li { 73 | text-align: center; 74 | background: #635aa7; 75 | padding: 20px; 76 | color: #e8e8e8; 77 | border: 1px solid #57527d; 78 | } 79 | 80 | .flashes li span { 81 | display: inline-block; 82 | margin: 0 20px; 83 | text-decoration: underline; 84 | cursor: pointer; 85 | } 86 | 87 | button:hover { 88 | cursor: pointer; 89 | } 90 | -------------------------------------------------------------------------------- /app/static/css/components/browse-sidebar.css: -------------------------------------------------------------------------------- 1 | .browse-sidebar { 2 | font-family: 'Open Sans', sans-serif; 3 | background: #355987; 4 | color: #f8f8f8; 5 | width: 280px; 6 | text-align: center; 7 | } 8 | 9 | .browse-sidebar h3{ 10 | background: #254977; 11 | font-size: 25px; 12 | font-weight: bold; 13 | line-height: 40px; 14 | height: 80px; 15 | padding: 10px 20px; 16 | } 17 | 18 | .browse-sidebar h3 div{ 19 | max-width: 280px; 20 | max-height: 80px; 21 | overflow: hidden; 22 | } 23 | 24 | .browse-sidebar ul label { 25 | display: block; 26 | font-size: 26px; 27 | line-height: 70px; 28 | height: 70px; 29 | font-weight: bold; 30 | letter-spacing: 0.7px; 31 | } 32 | 33 | .browse-sidebar ul li { 34 | display: block; 35 | font-size: 18px; 36 | line-height: 40px; 37 | height: 40px; 38 | cursor: pointer; 39 | } 40 | 41 | .browse-sidebar ul label, .browse-sidebar ul li { 42 | border-top: 1px solid #254977; 43 | /*border-bottom: 0;*/ 44 | } 45 | 46 | .browse-sidebar button { 47 | text-align: center; 48 | height: 88px; 49 | line-height: 88px; 50 | width: 280px; 51 | border: 0; 52 | border-top: 1px solid #153967; 53 | border-bottom: 1px solid #153967; 54 | padding: 0; 55 | outline: none; 56 | background: #254977; 57 | color: #f8f8f8; 58 | font-family: 'Open Sans', sans-serif; 59 | font-size: 28px; 60 | font-weight: bold; 61 | letter-spacing: 0.7px; 62 | } 63 | 64 | .browse-sidebar ul li:hover { 65 | background: #254977; 66 | } 67 | 68 | .browse-sidebar ul li.active { 69 | background: #153967; 70 | } 71 | 72 | .browse-sidebar button:hover { 73 | background: #153967; 74 | } -------------------------------------------------------------------------------- /app/templates/components/kifu-list.html: -------------------------------------------------------------------------------- 1 | {% from 'macros.html' import render_kifu_entry %} 2 | 3 | 4 |
5 | {% if items|length != 0 %} 6 |
7 | {% for item in items %} 8 | {{ render_kifu_entry(item) }} 9 | {% endfor %} 10 | 43 |
44 | {% else %} 45 |

No Kifus Found

46 | {% endif %} 47 |
48 | -------------------------------------------------------------------------------- /app/templates/components/comment-list.html: -------------------------------------------------------------------------------- 1 | {% from 'macros.html' import render_comment_entry %} 2 | 3 | 4 |
5 | {% if items|length != 0 %} 6 |
7 | {% for item in items %} 8 | {{ render_comment_entry(item) }} 9 | {% endfor %} 10 | 43 | {% else %} 44 |

No Comments Found

45 | {% endif %} 46 |
47 | -------------------------------------------------------------------------------- /app/static/css/components/notification.css: -------------------------------------------------------------------------------- 1 | .notification .header { 2 | font-weight: bold; 3 | line-height: 60px; 4 | padding: 0 20px; 5 | letter-spacing: 1px; 6 | font-size: 16px; 7 | color: #97afcf; 8 | cursor: pointer; 9 | -moz-user-select: none; 10 | -webkit-user-select: none; 11 | -ms-user-select: none; 12 | } 13 | 14 | .notification .header:hover { 15 | background: #254977!important; 16 | } 17 | 18 | .notification .header span { 19 | display: inline-block; 20 | line-height: 24px; 21 | padding: 5px 8px; 22 | margin: 0 4px; 23 | border-radius: 5px; 24 | background: #df4257; 25 | color: #c7dfff; 26 | } 27 | 28 | .notification .list { 29 | position: absolute; 30 | right: 0; 31 | background: #e8e8e8; 32 | color: #464646; 33 | font-family: 'Open Sans', sans-serif; 34 | font-size: 16px; 35 | max-height: 600px; 36 | overflow: auto; 37 | } 38 | 39 | .notification .list a { 40 | text-decoration: none; 41 | color: #464646; 42 | } 43 | 44 | .notification .list li { 45 | padding: 15px; 46 | border-bottom: 1px solid #c8c8c8; 47 | } 48 | 49 | .notification .list li:hover { 50 | cursor: pointer; 51 | background: #d8d8d8; 52 | color: #363636; 53 | } 54 | 55 | .notification .list li p { 56 | margin: 10px; 57 | } 58 | 59 | .notification .author { 60 | font-weight: bold; 61 | margin-right: 5px; 62 | } 63 | 64 | .notification .title { 65 | font-weight: bold; 66 | margin-left: 5px; 67 | } 68 | 69 | .notification p.content { 70 | height: 20px; 71 | line-height: 20px; 72 | max-height: 20px; 73 | max-width: 650px; 74 | white-space: nowrap; 75 | text-overflow: ellipsis; 76 | overflow: hidden; 77 | background: #c8c8c8; 78 | padding: 10px; 79 | } 80 | -------------------------------------------------------------------------------- /app/static/css/kifu/kifu.css: -------------------------------------------------------------------------------- 1 | main { 2 | margin: auto; 3 | position: absolute; 4 | top: 60px; 5 | bottom: 22px; 6 | left: 0; 7 | right: 0; 8 | margin: auto; 9 | width: 1330px; 10 | height: 750px; 11 | } 12 | 13 | #kifu-title { 14 | display: block; 15 | position: absolute; 16 | font-family: 'Open Sans', sans-serif; 17 | font-size: 40px; 18 | line-height: 70px; 19 | height: 70px; 20 | font-weight: bold; 21 | color: #f8f8f8; 22 | letter-spacing: 1px; 23 | overflow: hidden; 24 | max-width: 1300px; 25 | max-height: 70px; 26 | } 27 | 28 | #kifu-title[contentEditable="true"]:hover { 29 | text-shadow: 1px 0 0 #e8e8e8; 30 | } 31 | 32 | #kifu-title[contentEditable="true"]:focus { 33 | min-width: 300px; 34 | padding: 0 10px; 35 | background: #456997; 36 | } 37 | 38 | #info { 39 | position: absolute; 40 | top: 70px; 41 | margin-top: 10px; 42 | margin-right: 10px; 43 | width: 280px; 44 | height: 640px; 45 | padding: 10px 0; 46 | } 47 | 48 | #game { 49 | position: absolute; 50 | width: 620px; 51 | height: 660px; 52 | top: 70px; 53 | left: 290px; 54 | margin-top: 10px; 55 | } 56 | 57 | #board { 58 | width: 600px; 59 | height: 600px; 60 | margin: 0 10px; 61 | } 62 | 63 | #board:hover { 64 | cursor: pointer; 65 | } 66 | 67 | #control { 68 | position: absolute; 69 | width: 400px; 70 | height: 660px; 71 | margin: 10px; 72 | top: 70px; 73 | right: 0; 74 | } 75 | 76 | #navigation { 77 | width: 400px; 78 | height: 40px; 79 | } 80 | 81 | #edit { 82 | width: 400px; 83 | height: 40px; 84 | } 85 | 86 | #comment-list { 87 | width: 400px; 88 | height: 520px; 89 | overflow: auto; 90 | } 91 | 92 | #comment-form { 93 | width: 400px; 94 | height: 60px; 95 | } 96 | -------------------------------------------------------------------------------- /app/static/js/constants.js: -------------------------------------------------------------------------------- 1 | var constants = (function() { 2 | // increments in row, col for each direction 3 | var directions = [ 4 | [0, 1], // right 5 | [0, -1], // left 6 | [1, 0], // down 7 | [-1, 0] // up 8 | ]; 9 | 10 | // coordinates of star points based on board size 11 | var stars = { 12 | 19: [[3, 3], [3, 9], [3, 15], 13 | [9, 3], [9, 9], [9, 15], 14 | [15, 3], [15, 9], [15, 15]] 15 | }; 16 | 17 | // valid stone types 18 | var validStones = [ 19 | 'b', // black stone 20 | 'w' // white stone 21 | ]; 22 | 23 | // valid indicators 24 | var validIndicators = [ 25 | 'n', // next moves 26 | 'rw', // most recent move (white) 27 | 'rb' // most recent move (black) 28 | ]; 29 | 30 | // valid marker types: SGF property 31 | var validMarkers = { 32 | 't': 'TR', // triangle <-> TR 33 | 'c': 'CR', // circle <-> CR 34 | 's': 'SQ', // square <-> SQ 35 | 'x': 'MA' // X <-> MA 36 | }; 37 | 38 | // like validMarkers, but with keys and values reversed 39 | var validMarkerSGF = { 40 | 'TR': 't', 41 | 'CR': 'c', 42 | 'SQ': 's', 43 | 'MA': 'x' 44 | }; 45 | 46 | // cursor modes that determine what clicking on the board does 47 | var cursor = { 48 | 'PLAY_AND_SELECT': 1, // play a move or select a variation 49 | 'ADD_BLACK': 2, // add a black stone 50 | 'ADD_WHITE': 3, // add a white stone 51 | 'MARK_TRIANGLE': 4, // make triangle mark 52 | 'MARK_SQUARE': 5 // make square mark 53 | } 54 | 55 | return { 56 | dir: directions, 57 | stars: stars[config.sz], 58 | st: validStones, 59 | idct: validIndicators, 60 | mk: validMarkers, 61 | mkSGF: validMarkerSGF, 62 | cursor: cursor 63 | }; 64 | })(); 65 | -------------------------------------------------------------------------------- /app/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% from 'macros.html' import render_field %} 3 | 4 | {% block title %}Kifutalk - Conversations about Go made easy{% endblock %} 5 | {% block css %} 6 | {{ super() }} 7 | 8 | 9 | {% if current_user.is_authenticated %} 10 | 11 | {% else %} 12 | 13 | {% endif %} 14 | {% endblock %} 15 | {% block body %} 16 |
17 | {% include('components/banner.html') %} 18 | {% if current_user.is_authenticated %} 19 | {% include('components/user-dashboard.html') %} 20 | {% else %} 21 | {% include('components/login-signup.html') %} 22 | {% endif %} 23 |
24 | {% endblock %} 25 | {% block script %} 26 | 40 | {% endblock %} 41 | -------------------------------------------------------------------------------- /app/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import ValidationError, StringField, PasswordField, SubmitField, SelectField 3 | from wtforms.validators import DataRequired, Email, EqualTo, Length, Regexp 4 | 5 | from .models import User, Rank 6 | 7 | class SignUpForm(FlaskForm): 8 | sign_up_email = StringField('Email', validators=[ 9 | DataRequired(), 10 | Length(6, 35), 11 | Email() 12 | ]) 13 | sign_up_username = StringField('Username', validators=[ 14 | DataRequired(), 15 | Length(4, 20), 16 | Regexp( 17 | '^[A-Za-z0-9_.]*$', 18 | 0, 19 | 'Username must contain letters, dots, and underscores only' 20 | ) 21 | ]) 22 | sign_up_password = PasswordField('Password', validators=[ 23 | DataRequired(), 24 | Length(8, 64), 25 | EqualTo('sign_up_confirm', message='Passwords must match.') 26 | ]) 27 | sign_up_confirm = PasswordField('Re-enter Password', validators=[ 28 | DataRequired() 29 | ]) 30 | sign_up_rank = SelectField( 31 | 'Rank', 32 | choices=[(r.id, r.rank_en) for r in Rank.query.all()], 33 | coerce=int 34 | ) 35 | sign_up_submit = SubmitField('Sign Up') 36 | 37 | def validate_email(self, field): 38 | if User.query.filter_by(email=field.data).first(): 39 | raise(ValidationError('Email already in use.')) 40 | 41 | def validate_username(self, field): 42 | if User.query.filter_by(username=field.data).first(): 43 | raise(ValidationError('Username already in use.')) 44 | 45 | class LoginForm(FlaskForm): 46 | login_email = StringField('Email', validators=[ 47 | DataRequired(), 48 | Length(1, 64), 49 | Email() 50 | ]) 51 | login_password = PasswordField('Password', validators=[ 52 | DataRequired(), 53 | ]) 54 | login_submit = SubmitField('Log In') 55 | -------------------------------------------------------------------------------- /app/templates/kifu/kifu-edit.html: -------------------------------------------------------------------------------- 1 | {# 2 | edit-mode: button is disabled unless when editting 3 | nav-assist: button may be active to assist navigation 4 | #} 5 | 6 | 7 | 8 | 9 | {# 10 | add-stone will be enabled when the next move involves adding stones 11 | add-stone-menu will display all possible add moves 12 | #} 13 | 14 | 15 | 16 | 21 | 22 | 27 | 28 | 33 | 34 | 39 | -------------------------------------------------------------------------------- /app/templates/components/login-signup.html: -------------------------------------------------------------------------------- 1 | {# needs render_field #} 2 |
3 |
4 | 5 | 6 |
7 |
8 | {{ render_field(login_form.login_email) }} 9 | {{ render_field(login_form.login_password) }} 10 | {{ login_form.login_submit }} 11 | {{ login_form.csrf_token }} 12 |
13 | 22 |
23 | 49 | -------------------------------------------------------------------------------- /app/static/css/kifu/comment.css: -------------------------------------------------------------------------------- 1 | #comment-list { 2 | background: #e8e8e8; 3 | letter-spacing: 1px; 4 | line-height: 20px; 5 | margin: 0; 6 | clear: both; 7 | } 8 | 9 | .no-comment { 10 | height: 20px; 11 | width: 200px; 12 | margin: 250px 100px; 13 | text-align: center; 14 | color: #767676; 15 | font-style: italic; 16 | } 17 | 18 | .comment { 19 | padding: 15px; 20 | border-bottom: 1px solid #d8d8d8; 21 | color: #464646; 22 | } 23 | 24 | .comment.highlight { 25 | background: #d8d8d8; 26 | color: #363636; 27 | } 28 | 29 | .comment .user, .comment .time { 30 | display: inline-block; 31 | padding: 5px; 32 | font-size: 14px; 33 | } 34 | 35 | .comment .user { 36 | float: left; 37 | font-weight: bold; 38 | } 39 | 40 | .comment .time { 41 | float: right; 42 | } 43 | 44 | .comment .text { 45 | display: block; 46 | clear: both; 47 | margin: 15px 5px; 48 | padding: 5px; 49 | background: #d8d8d8; 50 | } 51 | 52 | .comment.highlight .text { 53 | background: #c8c8c8; 54 | } 55 | 56 | #comment-input { 57 | font-family: 'Open Sans', sans-serif; 58 | font-size: 16px; 59 | line-height: 20px; 60 | letter-spacing: 0.3px; 61 | width: 320px; 62 | height: 50px; 63 | padding: 5px; 64 | background: #f8f8f8; 65 | margin: 0; 66 | border: 0; 67 | float: left; 68 | resize: none; 69 | } 70 | 71 | #comment-input:focus{ 72 | outline: none; 73 | } 74 | 75 | #comment-input:disabled { 76 | background: #f2f2f2; 77 | cursor: not-allowed; 78 | } 79 | 80 | #comment-submit { 81 | font-family: 'Open Sans', sans-serif; 82 | width: 70px; 83 | height: 60px; 84 | padding: 10px; 85 | border: 0; 86 | margin: 0; 87 | float: right; 88 | letter-spacing: 1px; 89 | background: #e8e8e8; 90 | color: #363663; 91 | font-size: 14px; 92 | text-align: center; 93 | } 94 | 95 | #comment-submit:hover { 96 | background: #c8c8c8; 97 | } 98 | 99 | #comment-submit:disabled { 100 | color: #a8a8a8; 101 | background: #d8d8d8; 102 | cursor: not-allowed; 103 | } 104 | #comment-submit:disabled:hover { 105 | background: #d8d8d8; 106 | } 107 | -------------------------------------------------------------------------------- /app/templates/kifu/kifu-info-panel.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | {{ 6 | render_kifu_info_entry( 7 | auth_status, 8 | kifu.black_player, 9 | 'black-player', 10 | 'Add player name', 11 | 'Unknown' 12 | ) 13 | }} 14 | ( 15 | {{ 16 | render_kifu_info_entry( 17 | auth_status, 18 | kifu.black_rank, 19 | 'black-rank', 20 | 'Add rank', 21 | 'Unknown' 22 | ) 23 | }} 24 | ) 25 |
26 |
27 | 28 | 29 | 30 | {{ 31 | render_kifu_info_entry( 32 | auth_status, 33 | kifu.white_player, 34 | 'white-player', 35 | 'Add player name', 36 | 'Unknown' 37 | ) 38 | }} 39 | ( 40 | {{ 41 | render_kifu_info_entry( 42 | auth_status, 43 | kifu.white_rank, 44 | 'white-rank', 45 | 'Add rank', 46 | 'Unknown' 47 | ) 48 | }} 49 | ) 50 |
51 |
52 | 53 | {{ 54 | render_kifu_info_entry( 55 | auth_status, 56 | kifu.komi, 57 | 'kifu-komi', 58 | 'Add komi', 59 | 'Unknown' 60 | ) 61 | }} 62 |
63 |
64 | 65 | {{ 66 | render_kifu_info_entry( 67 | auth_status, 68 | kifu.result, 69 | 'kifu-result', 70 | 'Add result', 71 | 'Unknown' 72 | ) 73 | }} 74 |
75 |
76 | 77 | {{ 78 | render_kifu_info_entry( 79 | auth_status, 80 | kifu.description, 81 | 'kifu-description', 82 | 'Click to add description', 83 | 'No description available', 84 | 'p' 85 | ) 86 | }} 87 |
88 |

89 | {{ kifu.uploaded_on.split(' ')[0] }} by {{ kifu.owner }} 90 |

91 | -------------------------------------------------------------------------------- /app/static/js/browse.js: -------------------------------------------------------------------------------- 1 | var liList = document.querySelectorAll('.browse-sidebar ul li'); 2 | var sortBy = document.getElementById('sort-by'); 3 | var timeFrame = document.getElementById('time-frame'); 4 | var displayIn = document.getElementById('display-in'); 5 | var updateList = document.getElementById('update-list'); 6 | var navAnchors = document.querySelectorAll('.page-nav a'); 7 | 8 | var initListeners = function() { 9 | // add listeners to each browse option 10 | liList.forEach(function(li) { 11 | li.addEventListener('click', function(e) { 12 | // do something when the option is not currently chosen 13 | if (!li.classList.contains('active')) { 14 | li.parentNode.querySelector('.active').classList.remove('active'); 15 | li.parentNode.setAttribute('value', li.getAttribute('value')); 16 | li.classList.add('active'); 17 | } 18 | }); 19 | }); 20 | 21 | // add listener to update list button 22 | updateList.addEventListener('click', function(e) { 23 | window.location.replace(baseURL + '?page=1' + generateQueryString()); 24 | }); 25 | } 26 | 27 | var generateQueryString = function() { 28 | // ?page=1 is assumed 29 | return '&sort-by=' + sortBy.getAttribute('value') + '&time-frame=' + timeFrame.getAttribute('value') + '&display-in=' + displayIn.getAttribute('value'); 30 | } 31 | 32 | var readQueryString = function(sortByQ, timeFrameQ, displayInQ) { 33 | // set the li within the given ul to be active 34 | // and remove active from 35 | var readHelper = function(ul, queryValue) { 36 | ul.querySelectorAll('li').forEach(function(li) { 37 | if (li.getAttribute('value') !== queryValue) { 38 | li.classList.remove('active'); 39 | } else { 40 | li.classList.add('active'); 41 | ul.setAttribute('value', queryValue); 42 | } 43 | }); 44 | } 45 | 46 | readHelper(sortBy, sortByQ); 47 | readHelper(timeFrame, timeFrameQ); 48 | readHelper(displayIn, displayInQ); 49 | } 50 | 51 | // add query string to next and prev 52 | var modifyPageNav = function() { 53 | navAnchors.forEach(function(anc) { 54 | if (anc.getAttribute('href') !== '#') { 55 | console.log(anc.getAttribute('href')) 56 | anc.setAttribute('href', anc.getAttribute('href') + generateQueryString()); 57 | } 58 | }) 59 | } 60 | 61 | initListeners(); 62 | readQueryString(queryStringList[0], queryStringList[1], queryStringList[2]); 63 | modifyPageNav(); 64 | -------------------------------------------------------------------------------- /app/static/css/components/kifu-list.css: -------------------------------------------------------------------------------- 1 | .list-container { 2 | font-family: 'Open Sans', sans-serif; 3 | color: #f8f8f8; 4 | letter-spacing: 0.7px; 5 | line-height: 30px; 6 | background: #355987; 7 | width: 950px; 8 | } 9 | 10 | .list-container a { 11 | text-decoration: none; 12 | color: #f8f8f8; 13 | } 14 | 15 | .kifu-entry { 16 | border-top: 1px solid #254977; 17 | padding: 10px; 18 | padding-left: 20px; 19 | } 20 | 21 | .kifu-entry:hover { 22 | background: #254977; 23 | } 24 | 25 | .kifu-info { 26 | font-size: 18px; 27 | width: 750px; 28 | max-width: 760px; 29 | overflow: hidden; 30 | } 31 | 32 | .kifu-thumbnail { 33 | height: 125px; 34 | width: 125px; 35 | display: inline-block; 36 | vertical-align: top; 37 | } 38 | 39 | .kifu-info { 40 | display: inline-block; 41 | vertical-align: top; 42 | margin: 0 20px; 43 | } 44 | 45 | .kifu-title, .number-of-comments { 46 | display: inline-block; 47 | font-weight: bold; 48 | font-size: 24px; 49 | line-height: 30px; 50 | margin-bottom: 5px; 51 | } 52 | 53 | .kifu-title { 54 | margin-right: 5px; 55 | max-height: 30px; 56 | max-width: 565px; 57 | white-space: nowrap; 58 | text-overflow: ellipsis; 59 | overflow: hidden; 60 | vertical-align: top; 61 | } 62 | 63 | .player-info, .kifu-upload { 64 | text-align: right; 65 | } 66 | 67 | .page-nav { 68 | font-size: 0; 69 | line-height: 40px; 70 | height: 40px; 71 | font-weight: bold; 72 | border-top: 1px solid #254977; 73 | border-bottom: 1px solid #254977; 74 | } 75 | 76 | .page-nav svg { 77 | height: 40px; 78 | width: 25px; 79 | } 80 | 81 | .page-nav svg line, 82 | .page-nav svg polyline { 83 | stroke: #f8f8f8; 84 | } 85 | 86 | .page-nav span { 87 | font-size: 20px; 88 | display: inline-block; 89 | vertical-align: top; 90 | height: inherit; 91 | width: 40px; 92 | text-align: center; 93 | } 94 | 95 | .page-nav a { 96 | display: inline-block; 97 | vertical-align: top; 98 | padding: 0 214.5px; 99 | height: inherit; 100 | line-height: 40px; 101 | vertical-align: center; 102 | } 103 | 104 | .page-nav a:hover { 105 | background: #254977; 106 | } 107 | 108 | .no-kifu { 109 | font-size: 40px; 110 | font-weight: bold; 111 | letter-spacing: 1px; 112 | width: 950px; 113 | height: 770px; 114 | line-height: 770px; 115 | text-align: center; 116 | } 117 | -------------------------------------------------------------------------------- /app/templates/kifu/kifu-navigation.html: -------------------------------------------------------------------------------- 1 | 7 | 8 | 14 | 15 | 20 | 21 | 27 | 28 | 34 | 35 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /app/static/css/components/comment-list.css: -------------------------------------------------------------------------------- 1 | .list-container { 2 | font-family: 'Open Sans', sans-serif; 3 | background: #355987; 4 | } 5 | 6 | .list-container .comment-header { 7 | padding: 5px 15px; 8 | height: 30px; 9 | width: 960px; 10 | line-height: 30px; 11 | font-size: 18px; 12 | color: #e8e8e8; 13 | } 14 | 15 | .list-container .comment-header .author-info { 16 | font-weight: bold; 17 | margin-right: 10px; 18 | } 19 | 20 | .list-container .comment-header .kifu-title { 21 | font-weight: bold; 22 | margin-left: 10px; 23 | } 24 | 25 | .list-container .comment-header .comment-time { 26 | font-size: 16px; 27 | display: inline-block; 28 | float: right; 29 | } 30 | 31 | .list-container .comment-main { 32 | text-decoration: none; 33 | display: block; 34 | width: 920px; 35 | padding: 20px; 36 | background: #e8e8e8; 37 | border-left: 15px solid #355987; 38 | border-right: 15px solid #355987; 39 | } 40 | 41 | .list-container .comment-main:hover { 42 | background: #e0e0e0; 43 | } 44 | 45 | .list-container .comment-main .comment-content { 46 | background: #d0d0d0; 47 | padding: 10px; 48 | } 49 | 50 | .list-container .comment-main .comment-content p { 51 | padding: 0; 52 | width: 900px; 53 | max-height: 25px; 54 | max-width: 900px; 55 | font-size: 18px; 56 | font-weight: 500; 57 | line-height: 25px; 58 | white-space: nowrap; 59 | text-overflow: ellipsis; 60 | overflow: hidden; 61 | color: #525252; 62 | } 63 | 64 | .list-container .page-nav { 65 | width: 990px; 66 | background: #355987; 67 | font-size: 0; 68 | line-height: 30px; 69 | height: 30px; 70 | font-weight: bold; 71 | } 72 | 73 | .list-container .page-nav svg { 74 | height: 30px; 75 | width: 25px; 76 | } 77 | 78 | .list-container .page-nav svg line, 79 | .list-container .page-nav svg polyline { 80 | stroke: #f8f8f8; 81 | } 82 | 83 | .list-container .page-nav span { 84 | color: #f8f8f8; 85 | font-size: 18px; 86 | display: inline-block; 87 | height: inherit; 88 | width: 40px; 89 | text-align: center; 90 | vertical-align: top; 91 | } 92 | 93 | .list-container .page-nav a { 94 | display: inline-block; 95 | vertical-align: top; 96 | padding: 0 225px; 97 | height: inherit; 98 | line-height: 40px; 99 | } 100 | 101 | .list-container .page-nav a:hover { 102 | background: #254977; 103 | } 104 | 105 | .list-container .no-comment { 106 | color: #f8f8f8; 107 | font-size: 40px; 108 | font-weight: bold; 109 | letter-spacing: 1px; 110 | width: 990px; 111 | height: 780px; 112 | line-height: 780px; 113 | text-align: center; 114 | } 115 | -------------------------------------------------------------------------------- /app/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block title %}{% endblock %} 6 | 7 | {% block css %} 8 | 9 | 10 | 11 | 12 | {% endblock %} 13 | 14 | 15 | {# Only navigation bar is included #} 16 |
17 |

Kifutalk

18 | 24 |
25 | 26 | {# flash messages #} 27 | {% with messages = get_flashed_messages() %} 28 | {% if messages %} 29 | 34 | {% endif %} 35 | {% endwith %} 36 | 37 | {% block body %} 38 | {% endblock %} 39 | 40 | 43 | 69 | 70 | 71 | {% block script %} 72 | {% endblock %} 73 | 74 | 75 | -------------------------------------------------------------------------------- /app/static/css/kifu/action-bar.css: -------------------------------------------------------------------------------- 1 | #action-bar { 2 | background: #4b5e76; 3 | width: 600px; 4 | height: 60px; 5 | margin: 0 10px; 6 | font-size: 0; 7 | } 8 | 9 | #action-bar .action-container { 10 | vertical-align: top; 11 | display: inline-block; 12 | } 13 | 14 | #action-bar button { 15 | text-align: center; 16 | vertical-align: top; 17 | height: 60px; 18 | width: 150px; 19 | padding: 0; 20 | display: block; 21 | background: none; 22 | border: 0; 23 | } 24 | 25 | #action-bar button:hover { 26 | background: #3b4e66; 27 | } 28 | 29 | #action-bar button span { 30 | vertical-align: top; 31 | line-height: 60px; 32 | font-size: 16px; 33 | letter-spacing: 0.5px; 34 | font-family: 'Open Sans', sans-serif; 35 | color: #e8e8e8; 36 | margin-left: 5px; 37 | } 38 | 39 | #action-bar svg { 40 | height: 60px; 41 | width: 30px; 42 | } 43 | 44 | #unstar svg path { 45 | stroke: #F4B812; 46 | stroke-width: 0.5px; 47 | } 48 | 49 | #action-bar #share.active { 50 | background: #3b4e66; 51 | } 52 | 53 | #action-bar .share-dropdown { 54 | position: absolute; 55 | width: 260px; 56 | background: #d8d8d8; 57 | padding: 20px; 58 | text-align: center; 59 | } 60 | 61 | #action-bar .share-dropdown label { 62 | display: block; 63 | font-size: 16px; 64 | line-height: 20px; 65 | padding: 15px 5px; 66 | background: #c8c8c8; 67 | font-weight: bold; 68 | cursor: pointer; 69 | border: 1px solid #a8a8a8; 70 | letter-spacing: 0.2px; 71 | } 72 | 73 | #action-bar .share-dropdown label:hover { 74 | background: #b8b8b8; 75 | } 76 | 77 | #action-bar .share-dropdown label.active { 78 | background: #a8a8a8; 79 | } 80 | 81 | #action-bar .share-dropdown input { 82 | margin-top: 5px; 83 | padding: 5px 10px; 84 | background: #e8e8e8; 85 | color: #363636; 86 | width: 240px; 87 | height: 30px; 88 | line-height: 30px; 89 | font-size: 16px; 90 | border: 0; 91 | outline: none; 92 | cursor: text; 93 | } 94 | 95 | .confirm-delete button#delete-yes, 96 | .confirm-delete button#delete-no { 97 | vertical-align: top; 98 | width: 75px; 99 | height: 60px; 100 | display: inline-block; 101 | font-family: 'Open Sans', sans-serif; 102 | font-size: 16px; 103 | letter-spacing: 0.3px; 104 | line-height: 60px; 105 | color: #f8f8f8; 106 | } 107 | 108 | .confirm-delete button#delete-yes { 109 | background: #df5277; 110 | } 111 | .confirm-delete button#delete-yes:hover { 112 | background: #cf3257; 113 | } 114 | .confirm-delete button#delete-no { 115 | background: #39ac78; 116 | } 117 | .confirm-delete button#delete-no:hover { 118 | background: #298c58; 119 | } 120 | -------------------------------------------------------------------------------- /app/templates/upload.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% from 'macros.html' import render_field %} 3 | 4 | {% block title %}Kifutalk - Upload Kifu{% endblock %} 5 | {% block css %} 6 | {{ super() }} 7 | 8 | {% endblock %} 9 | {% block body %} 10 |
11 |
12 |
13 |

Choose an SGF file

14 |
15 | 18 | 19 |
20 |
21 | 22 | 23 |
24 |
25 | 26 | 27 |
28 |
29 |
30 |

Tell us a bit about this kifu

31 |
32 | 33 | 34 |
35 |
36 | 37 | 38 |
39 |
40 |
41 |

Ready?

42 |
43 | 44 |
45 |
46 |
47 |
48 | {% endblock %} 49 | {% block script %} 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | {% endblock %} 65 | -------------------------------------------------------------------------------- /app/templates/macros.html: -------------------------------------------------------------------------------- 1 | {% macro render_field(field) %} 2 |
3 | {{ field.label }} {{ field }} 4 |
5 | {% if field.errors %} 6 | 11 | {% endif %} 12 | {% endmacro %} 13 | 14 | {% macro render_kifu_entry(item) %} 15 | {% with 16 | kifu=item[0], 17 | user=item[1], 18 | comment_count=0 if item[2] is none else item[2] 19 | %} 20 | 21 |
22 | 23 |
24 |

{{ kifu.title }}

25 |

({{ comment_count }} comments)

26 |

Black: {{ kifu.black_player }} ({{ kifu.black_rank }})

27 |

White: {{ kifu.white_player }} ({{ kifu.white_rank }})

28 |

Uploaded on {{ kifu.uploaded_on.strftime('%Y-%m-%d') }} by {{ user.username }}

29 |
30 |
31 |
32 | {% endwith %} 33 | {% endmacro %} 34 | 35 | {% macro render_kifu_info_entry(auth_status, entry, entry_class, auth_fallback_text, unauth_fallback_text, tag="span") %} 36 | {% if entry|trim == '' %} 37 | {% if auth_status == 2 %} 38 | <{{ tag }} contentEditable="true" class="fallback {{ entry_class }}"> 39 | {{ auth_fallback_text }} 40 | 41 | {% else %} 42 | <{{ tag }} class="fallback {{ entry_class }}"> 43 | {{ unauth_fallback_text }} 44 | 45 | {% endif %} 46 | {% else %} 47 | {% if auth_status == 2 %} 48 | <{{ tag }} contentEditable="true" class="{{ entry_class }}"> 49 | {{ entry }} 50 | 51 | {% else %} 52 | <{{ tag }} class="{{ entry_class }}"> 53 | {{ entry }} 54 | 55 | {% endif %} 56 | {% endif %} 57 | {% endmacro %} 58 | 59 | {% macro render_comment_entry(item) %} 60 | {% with 61 | comment=item[0], 62 | kifu_title=item[1], 63 | username=item[2], 64 | user_rank=item[3] 65 | %} 66 |
67 |

68 | {{ username }} ({{ user_rank }}) 69 | commented on 70 | {{ kifu_title }} 71 | at {{ comment.timestamp.strftime('%Y-%m-%d %H:%M:%S') }} 72 |

73 | 74 |
75 |

{{ comment.content }}

76 |
77 |
78 |
79 | {% endwith %} 80 | {% endmacro %} 81 | -------------------------------------------------------------------------------- /app/static/css/kifu/info-panel.css: -------------------------------------------------------------------------------- 1 | #info { 2 | background: #4b5e76; 3 | color: #e8e8e8; 4 | font-family: 'Open Sans', sans-serif; 5 | letter-spacing: 0.7px; 6 | } 7 | 8 | #info .info-entry { 9 | border-bottom: 1px solid #3b4e66; 10 | padding-left: 8px; 11 | height: 50px; 12 | line-height: 50px; 13 | font-size: 0; 14 | } 15 | 16 | #info .info-entry span, #info .info-entry label { 17 | font-size: 18px; 18 | } 19 | 20 | #info .black span, #info .white span { 21 | display: inline-block; 22 | } 23 | 24 | #info .black-player, #info .white-player { 25 | margin-right: 5px; 26 | } 27 | 28 | #info svg { 29 | vertical-align: top; 30 | display: inline-block; 31 | height: 50px; 32 | width: 30px; 33 | margin-right: 5px; 34 | } 35 | 36 | #info svg.black-circle { 37 | fill: #363636; 38 | } 39 | 40 | #info svg.white-circle { 41 | fill: #f8f8f8; 42 | } 43 | 44 | #info .black-player, #info .white-player { 45 | max-width: 140px; 46 | max-height: 50px; 47 | overflow: hidden; 48 | vertical-align: top; 49 | } 50 | 51 | #info .black-rank, #info .white-rank { 52 | max-width: 75px; 53 | max-height: 50px; 54 | overflow: hidden; 55 | vertical-align: top; 56 | } 57 | 58 | #info .parenthesis { 59 | margin: 0 1px; 60 | } 61 | 62 | #info .komi span, #info .result span { 63 | display: inline-block; 64 | max-width: 140px; 65 | max-height: 50px; 66 | overflow: hidden; 67 | vertical-align: top; 68 | } 69 | 70 | #info span.fallback, #info p.fallback { 71 | letter-spacing: 0; 72 | font-size: 16px; 73 | } 74 | 75 | #info label { 76 | font-weight: bold; 77 | margin-right: 5px; 78 | } 79 | 80 | #info .description label { 81 | display: block; 82 | margin-left: 8px; 83 | height: 50px; 84 | line-height: 50px; 85 | font-size: 18px; 86 | } 87 | 88 | #info .description p { 89 | margin: 0 8px; 90 | padding: 5px; 91 | width: 250px; 92 | height: 340px; 93 | max-height: 340px; 94 | overflow: auto; 95 | font-size: 16px; 96 | line-height: 24px; 97 | letter-spacing: 0.5px; 98 | } 99 | 100 | #info .upload-info { 101 | border-top: 1px solid #3b4e66; 102 | padding-left: 8px; 103 | height: 45px; 104 | line-height: 45px; 105 | font-size: 15px; 106 | } 107 | 108 | #info span[contentEditable="true"]:hover, 109 | #info p[contentEditable="true"]:hover 110 | { 111 | text-shadow: 1px 0 0 #e8e8e8; 112 | } 113 | 114 | #info span[contentEditable="true"]:focus { 115 | min-width: 35px; 116 | padding: 0 5px; 117 | background: #5b6e86; 118 | line-height: 40px; 119 | margin: 5px 0; 120 | } 121 | 122 | #info p[contentEditable="true"]:focus { 123 | background: #5b6e86; 124 | } 125 | 126 | #info span.black-player[contentEditable="true"]:focus, 127 | #info span.white-player[contentEditable="true"]:focus 128 | { 129 | min-width: 70px; 130 | } 131 | 132 | #info span.kifu-komi[contentEditable="true"]:focus, 133 | #info span.kifu-result[contentEditable="true"]:focus 134 | { 135 | min-width: 80px; 136 | } 137 | -------------------------------------------------------------------------------- /app/static/css/kifu/nav_edit.css: -------------------------------------------------------------------------------- 1 | .navigation, .edit { 2 | display: inline-block; 3 | background: #e8e8e8; 4 | margin: auto; 5 | padding: 0; 6 | border: 0; 7 | letter-spacing: 0; 8 | font-family: monospace; 9 | height: 40px; 10 | font-size: 24px; 11 | color: #363636; 12 | vertical-align: middle; 13 | outline: none; 14 | } 15 | 16 | #navigation, #edit { 17 | /* remove space between inline buttons */ 18 | font-size: 0; 19 | } 20 | 21 | .navigation:hover, .edit:hover { 22 | background: #bfbfbf; 23 | } 24 | 25 | .navigation { 26 | width: 50px; 27 | } 28 | 29 | .navigation svg, 30 | .edit svg, 31 | .navigation svg polygon, 32 | .edit svg polygon, 33 | .navigation svg line, 34 | .edit svg line 35 | { 36 | height: 20px; 37 | width: 20px; 38 | stroke: #363636; 39 | display: block; 40 | margin: auto; 41 | vertical-align: middle; 42 | } 43 | 44 | button:disabled svg, 45 | button:disabled svg polygon, 46 | button:disabled svg line 47 | { 48 | stroke: #a8a8a8; 49 | } 50 | 51 | #add-black svg { 52 | stroke-width: 1px; 53 | stroke: #a8a8a8; 54 | fill: #464646; 55 | } 56 | 57 | #add-white svg { 58 | stroke-width: 1px; 59 | stroke: #c8c8c8; 60 | fill: #f8f8f8; 61 | } 62 | 63 | #add-black:disabled svg { 64 | fill: #666666; 65 | } 66 | 67 | #add-white:disabled svg { 68 | fill: #f0f0f0; 69 | } 70 | 71 | .edit { 72 | width: 50px; 73 | } 74 | 75 | #add-stone { 76 | width: 100px; 77 | } 78 | 79 | /* buttons with words */ 80 | #toggle-edit, #save, #cancel, #delete-node, #pass, #add-stone { 81 | font-family: 'Open Sans', sans-serif; 82 | font-size: 16px; 83 | } 84 | 85 | #toggle-edit { 86 | width: 150px; 87 | font-weight: bold; 88 | letter-spacing: 0.5px; 89 | } 90 | 91 | #toggle-edit:hover { 92 | background: #bfbfbf; 93 | } 94 | 95 | #toggle-edit:disabled:hover { 96 | background: #dddddd; 97 | } 98 | 99 | #save { 100 | width: 60px; 101 | background: #39ac78; 102 | color: #f8f8f8; 103 | } 104 | 105 | #cancel { 106 | width: 90px; 107 | background: #df5277; 108 | color: #f8f8f8; 109 | } 110 | 111 | #save:hover { 112 | background: #298c58; 113 | } 114 | 115 | #cancel:hover { 116 | background: #cf3257; 117 | } 118 | 119 | #pass { 120 | width: 70px; 121 | } 122 | 123 | #delete-node { 124 | width: 130px; 125 | } 126 | 127 | button:disabled { 128 | cursor: not-allowed; 129 | background: #dddddd; 130 | color: #a8a8a8; 131 | } 132 | 133 | button:disabled:hover { 134 | background: #dddddd; 135 | } 136 | 137 | .active { 138 | background: #c8c8c8; 139 | } 140 | 141 | #add-stone-menu { 142 | display: inline-block; 143 | position: absolute; 144 | width: 100px; 145 | top: 80px; 146 | max-height: 240px; 147 | overflow: auto; 148 | color: black; 149 | } 150 | 151 | #add-stone-menu li { 152 | background: #c8c8c8; 153 | color: #464646; 154 | display: block; 155 | text-align: center; 156 | height: 30px; 157 | font-size: 14px; 158 | line-height: 30px; 159 | padding: 10px; 160 | } 161 | 162 | #add-stone-menu li:hover { 163 | cursor: pointer; 164 | background: #b8b8b8; 165 | } 166 | -------------------------------------------------------------------------------- /app/static/css/upload.css: -------------------------------------------------------------------------------- 1 | .container { 2 | font-family: 'Open Sans', sans-serif; 3 | font-size: 0; 4 | color: #f8f8f8; 5 | position: absolute; 6 | width: 1250px; 7 | height: 610px; 8 | top: 60px; 9 | bottom: 22px; 10 | left: 0; 11 | right: 0; 12 | margin: auto; 13 | } 14 | 15 | /* dimensions */ 16 | section#sgf-input { 17 | width: 450px; 18 | } 19 | section#kifu-info { 20 | width: 400px; 21 | } 22 | section#kifu-submit { 23 | width: 160px; 24 | } 25 | 26 | section { 27 | vertical-align: top; 28 | display: inline-block; 29 | padding: 25px; 30 | margin: 0 15px; 31 | background: #355987; 32 | } 33 | 34 | section .subcontainer { 35 | margin-top: 25px; 36 | } 37 | 38 | label { 39 | text-align: left; 40 | margin: 10px 0; 41 | display: block; 42 | font-size: 20px; 43 | font-weight: bold; 44 | letter-spacing: 0.5px; 45 | } 46 | 47 | h3 { 48 | text-align: left; 49 | font-size: 25px; 50 | font-weight: bold; 51 | letter-spacing: 0.8px; 52 | margin-bottom: 45px; 53 | } 54 | 55 | .errors { 56 | background: #736ab7; 57 | font-size: 15px; 58 | padding: 10px; 59 | } 60 | 61 | input[type="text"] { 62 | box-sizing: border-box; 63 | width: 100%; 64 | padding: 8px; 65 | font-size: 16px; 66 | font-family: 'Open Sans', sans-serif; 67 | letter-spacing: 1px; 68 | background: #f8f8f8; 69 | color: #464646; 70 | border: 0; 71 | outline: none; 72 | } 73 | 74 | #sgf-file-container { 75 | font-size: 0; 76 | } 77 | 78 | #sgf-file-container input { 79 | display: none; 80 | } 81 | 82 | #sgf-file-container label { 83 | font-size: 20px; 84 | cursor: pointer; 85 | text-align: center; 86 | background: #254977; 87 | padding: 30px 0; 88 | margin-bottom: 0; 89 | } 90 | 91 | #sgf-file-container label:hover { 92 | background: #153967; 93 | } 94 | 95 | #sgf-file-container .chosen-file-name { 96 | overflow: hidden; 97 | cursor: initial; 98 | font-weight: normal; 99 | font-size: 16px; 100 | padding: 10px; 101 | margin-top: 0; 102 | border-top: 1px solid #153967; 103 | } 104 | #sgf-file-container .chosen-file-name:hover { 105 | background: #254977; 106 | } 107 | 108 | textarea { 109 | box-sizing: border-box; 110 | width: 100%; 111 | resize: none; 112 | padding: 8px; 113 | font-size: 16px; 114 | line-height: 20px; 115 | font-family: 'Open Sans', sans-serif; 116 | letter-spacing: 1px; 117 | background: #f8f8f8; 118 | color: #464646; 119 | border: 0; 120 | outline: none; 121 | } 122 | 123 | textarea[name="sgf-text"] { 124 | height: 260px; 125 | } 126 | textarea[name="kifu-description"] { 127 | height: 363px; 128 | } 129 | 130 | #upload { 131 | cursor: pointer; 132 | text-align: center; 133 | width: 100%; 134 | padding: 20px; 135 | font-family: 'Open Sans', sans-serif; 136 | font-size: 21px; 137 | font-weight: bold; 138 | letter-spacing: 0.5px; 139 | background: #254977; 140 | color: #f8f8f8; 141 | border: 0; 142 | } 143 | 144 | #upload:hover { 145 | background: #153967; 146 | } 147 | 148 | #upload:disabled { 149 | color: #a8a8a8; 150 | } 151 | 152 | #upload:disabled:hover { 153 | cursor: initial; 154 | background: #254977; 155 | } 156 | -------------------------------------------------------------------------------- /app/templates/kifu/kifu.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% from 'macros.html' import render_kifu_info_entry %} 3 | 4 | {% block title %}Kifutalk - {{ kifu.title }}{% endblock %} 5 | {% block css %} 6 | {{ super() }} 7 | 8 | 9 | 10 | 11 | 12 | 13 | {% endblock %} 14 | {% block body %} 15 |
16 | {% if kifu.title|trim == '' %} 17 | {% if auth_status == 2 %} 18 |

19 | Click to add title 20 |

21 | {% else %} 22 |

23 | No title provided 24 |

25 | {% endif %} 26 | {% else %} 27 | {% if auth_status == 2 %} 28 |

29 | {{ kifu.title }} 30 |

31 | {% else %} 32 |

33 | {{ kifu.title }} 34 |

35 | {% endif %} 36 | {% endif %} 37 |
38 | {% include('kifu/kifu-info-panel.html') %} 39 |
40 |
41 | {% include('kifu/kifu-action-bar.html') %} 42 | 43 |
44 |
45 | 48 |
49 | {% include('kifu/kifu-edit.html') %} 50 |
51 |
52 | {% include('kifu/kifu-comment.html') %} 53 |
54 |
55 |
56 | {% endblock %} 57 | 58 | {% block script %} 59 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | {% endblock %} -------------------------------------------------------------------------------- /app/static/css/components/login-signup.css: -------------------------------------------------------------------------------- 1 | .login-signup { 2 | width: 500px; 3 | background: #355987; 4 | color: #f8f8f8; 5 | font-size: 0; 6 | } 7 | 8 | #login-form, #sign-up-form { 9 | margin-top: 40px; 10 | } 11 | 12 | .field { 13 | width: 450px; 14 | margin: 25px; 15 | margin-bottom: 0; 16 | height: 75px; 17 | } 18 | 19 | .field label { 20 | display: block; 21 | font-weight: bold; 22 | margin-bottom: 15px; 23 | font-size: 18px; 24 | letter-spacing: 1.5px; 25 | } 26 | 27 | .field input { 28 | background: #f8f8f8; 29 | border: 1px solid #464646; 30 | width: 428px; 31 | padding: 10px; 32 | font-size: 16px; 33 | font-family: 'Open Sans', sans-serif; 34 | letter-spacing: 1px; 35 | } 36 | 37 | .switch button { 38 | width: 250px; 39 | height: 60px; 40 | border: 0; 41 | outline: none; 42 | font-family: 'Open Sans', sans-serif; 43 | font-size: 20px; 44 | font-weight: bold; 45 | letter-spacing: 1px; 46 | background: #254977; 47 | color: #f8f8f8; 48 | } 49 | 50 | input[type="submit"] { 51 | margin-top: 40px; 52 | width: 500px; 53 | height: 60px; 54 | border: 0; 55 | outline: none; 56 | font-family: 'Open Sans', sans-serif; 57 | font-size: 20px; 58 | font-weight: bold; 59 | letter-spacing: 1px; 60 | background: #254977; 61 | color: #d8d8d8; 62 | cursor: pointer; 63 | } 64 | 65 | .switch button.active { 66 | background: #153967; 67 | color: #f8f8f8; 68 | } 69 | 70 | input[type="submit"]:hover { 71 | background: #153967; 72 | } 73 | 74 | select { 75 | display: block; 76 | width: 450px; 77 | font-size: 16px; 78 | font-family: 'Open Sans', sans-serif; 79 | letter-spacing: 1px; 80 | background: #f8f8f8; 81 | padding: 11px; 82 | border: 1px solid #464646; 83 | -webkit-appearance: none; 84 | -moz-appearance: none; 85 | background-position: right 50%; 86 | background-repeat: no-repeat; 87 | background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAMCAYAAABSgIzaAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNSBNYWNpbnRvc2giIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NDZFNDEwNjlGNzFEMTFFMkJEQ0VDRTM1N0RCMzMyMkIiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NDZFNDEwNkFGNzFEMTFFMkJEQ0VDRTM1N0RCMzMyMkIiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo0NkU0MTA2N0Y3MUQxMUUyQkRDRUNFMzU3REIzMzIyQiIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo0NkU0MTA2OEY3MUQxMUUyQkRDRUNFMzU3REIzMzIyQiIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PuGsgwQAAAA5SURBVHjaYvz//z8DOYCJgUxAf42MQIzTk0D/M+KzkRGPoQSdykiKJrBGpOhgJFYTWNEIiEeAAAMAzNENEOH+do8AAAAASUVORK5CYII=); 88 | } 89 | 90 | option { 91 | font-size: 16px; 92 | font-family: 'Open Sans', sans-serif; 93 | letter-spacing: 1px; 94 | } 95 | 96 | .errors { 97 | background: #736ab7; 98 | width: 429px; 99 | margin-left: 25px; 100 | font-size: 15px; 101 | padding: 10px; 102 | } 103 | -------------------------------------------------------------------------------- /app/templates/kifu/kifu-action-bar.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 9 |
10 | 11 |
12 | 18 |
19 | 20 |
21 | 27 |
28 | 29 |
30 | 36 | 40 |
41 | 42 |
43 | 49 | 53 |
54 |
55 | -------------------------------------------------------------------------------- /app/models.py: -------------------------------------------------------------------------------- 1 | from flask import current_app 2 | from flask_login import UserMixin 3 | from itsdangerous import TimedJSONWebSignatureSerializer as Serializer 4 | 5 | import os 6 | 7 | from . import db, bcrypt 8 | 9 | class User(UserMixin, db.Model): 10 | __tablename__ = 'users' 11 | id = db.Column(db.Integer, primary_key=True) 12 | username = db.Column(db.String(64), unique=True, index=True, nullable=False) 13 | email = db.Column(db.String(64), unique=True, index=True, nullable=False) 14 | password_hash = db.Column(db.String(128), nullable=False) 15 | confirmed = db.Column(db.Boolean, default=False) 16 | signed_up_on = db.Column(db.DateTime) 17 | confirmed_on = db.Column(db.DateTime) 18 | role_id = db.Column(db.Integer, db.ForeignKey('roles.id')) 19 | rank_id = db.Column(db.Integer, db.ForeignKey('ranks.id')) 20 | 21 | @property 22 | def password(self): 23 | raise AttributeError('password is not readable') 24 | 25 | @property 26 | def rank(self): 27 | return Rank.query.get(self.rank_id).rank_en; 28 | 29 | @password.setter 30 | def password(self, password): 31 | self.password_hash = bcrypt.generate_password_hash(password) 32 | 33 | def verify_password(self, password): 34 | return bcrypt.check_password_hash(self.password_hash, password) 35 | 36 | def generate_confirmation_token(self, expiration=3600): 37 | s = Serializer(current_app.config['SECRET_KEY'], expiration) 38 | return s.dump({'confirm': self.id}) 39 | 40 | def confirm(self, token): 41 | s = Serializer(current_app.config['SECRET_KEY']) 42 | try: 43 | data = s.loads(token) 44 | except: 45 | return False 46 | if data.get('confirm') != self.id: 47 | return False 48 | self.confirmed = True 49 | db.session.add(self) 50 | db.session.commit() 51 | return True 52 | 53 | @property 54 | def unread_notifications(self): 55 | return [n.serialize for n in Notification.query.filter_by(receiver_id=self.id, read=False).order_by(Notification.id.desc()).all()] 56 | 57 | class Role(db.Model): 58 | __tablename__ = 'roles' 59 | id = db.Column(db.Integer, primary_key=True) 60 | name = db.Column(db.String(64), unique=True) 61 | 62 | class Kifu(db.Model): 63 | __tablename__ = 'kifus' 64 | # id also used as file paths, e.g. /kifus/1.sgf for kifu with id 1 65 | id = db.Column(db.Integer, primary_key=True) 66 | uploaded_on = db.Column(db.DateTime) 67 | modified_on = db.Column(db.DateTime) 68 | owner_id = db.Column(db.Integer, db.ForeignKey('users.id')) 69 | 70 | # kifu info 71 | title = db.Column(db.String(512), nullable=False) 72 | description = db.Column(db.Text, default='') 73 | black_player = db.Column(db.String(128), default='') 74 | white_player = db.Column(db.String(128), default='') 75 | black_rank = db.Column(db.String(16), default='') 76 | white_rank = db.Column(db.String(16), default='') 77 | komi = db.Column(db.String(8), default='') 78 | result = db.Column(db.String(16), default='') 79 | 80 | @property 81 | def filepath(self): 82 | return os.path.join( 83 | current_app.config['SGF_FOLDER'], 84 | str(self.id) + '.sgf' 85 | ) 86 | 87 | @property 88 | def imagepath(self): 89 | return os.path.join( 90 | current_app.config['THUMBNAIL_FOLDER'], 91 | str(self.id) + '.jpg' 92 | ) 93 | 94 | @property 95 | def sgf(self): 96 | with open(self.filepath, 'r') as f: 97 | return f.read() 98 | 99 | @property 100 | def serialize(self): 101 | uploaded_on = self.uploaded_on.strftime('%Y-%m-%d %H:%M:%S') 102 | return { 103 | 'id': self.id, 104 | 'owner': User.query.get(self.owner_id).username, 105 | 'title': self.title, 106 | 'description': self.description, 107 | 'black_player': self.black_player, 108 | 'white_player': self.white_player, 109 | 'black_rank': self.black_rank, 110 | 'white_rank': self.white_rank, 111 | 'komi': self.komi, 112 | 'result': self.result, 113 | 'uploaded_on': uploaded_on, 114 | 'sgf': self.sgf 115 | } 116 | 117 | def update_sgf(self, newSGF): 118 | with open(self.filepath, 'w') as f: 119 | f.write(newSGF) 120 | 121 | class Comment(db.Model): 122 | __tablename__ = 'comments' 123 | id = db.Column(db.Integer, primary_key=True) 124 | content = db.Column(db.Text, nullable=False) 125 | timestamp = db.Column(db.DateTime, nullable=False) 126 | author = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) 127 | kifu_id = db.Column(db.Integer, db.ForeignKey('kifus.id'), nullable=False) 128 | node_id = db.Column(db.Integer, nullable=False) 129 | 130 | @property 131 | def serialize(self): 132 | return { 133 | 'id': self.id, 134 | 'content': self.content, 135 | 'timestamp': self.timestamp.strftime('%Y-%m-%d %H:%M:%S'), 136 | 'author_id': self.author, 137 | 'author_username': User.query.get(self.author).username, 138 | 'author_rank': User.query.get(self.author).rank, 139 | 'kifu_id': self.kifu_id, 140 | 'node_id': self.node_id 141 | } 142 | 143 | class KifuStar(db.Model): 144 | __tablename__='kifustars' 145 | id = db.Column(db.Integer, primary_key=True) 146 | user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) 147 | kifu_id = db.Column(db.Integer, db.ForeignKey('kifus.id'), nullable=False) 148 | 149 | class Rank(db.Model): 150 | __tablename__ = 'ranks' 151 | id = db.Column(db.Integer, primary_key=True) 152 | rank_en = db.Column(db.String(16)) 153 | rank_cn = db.Column(db.String(16)) 154 | 155 | class Notification(db.Model): 156 | __tablename__ = 'notifications' 157 | id = db.Column(db.Integer, primary_key=True) 158 | # category == 1: kifu that user uploaded received a new comment 159 | # category == 2: a node that user commented on received another comment 160 | category = db.Column(db.Integer, nullable=False) 161 | # user receiving the notification 162 | receiver_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) 163 | # comment that triggered the notification 164 | # from the comment, kifu_id, node_id, and commenter_id will be known 165 | comment_id = db.Column(db.Integer, db.ForeignKey('comments.id'), nullable=False) 166 | # whether notification has already been read 167 | read = db.Column(db.Boolean, default=False) 168 | 169 | @property 170 | def serialize(self): 171 | comment = Comment.query.get(self.comment_id) 172 | kifu = Kifu.query.get(comment.kifu_id) 173 | return { 174 | 'id': self.id, 175 | 'category': self.category, 176 | 'read': self.read, 177 | 'comment_id': self.comment_id, 178 | 'content': comment.content, 179 | 'timestamp': comment.timestamp.strftime('%Y-%m-%d %H:%M:%S'), 180 | 'kifu_id': kifu.id, 181 | 'kifu_title': kifu.title, 182 | 'node_id': comment.node_id, 183 | 'author_username': User.query.get(comment.author).username, 184 | 'author_rank': User.query.get(comment.author).rank 185 | } 186 | -------------------------------------------------------------------------------- /app/static/js/upload.js: -------------------------------------------------------------------------------- 1 | var fileInput = document.querySelector('#sgf-file-container input'); 2 | var textInput = document.querySelector('#sgf-text-container textarea'); 3 | var urlInput = document.querySelector('#sgf-url-container input'); 4 | var titleInput = document.querySelector('#kifu-info input'); 5 | var descriptionInput = document.querySelector('#kifu-info textarea'); 6 | var upload = document.querySelector('#upload'); 7 | 8 | var fr = new FileReader(); 9 | var urlXHR = new XMLHttpRequest(); 10 | 11 | var invalidSGFMsg = 'Your file does not contain valid SGF content.'; 12 | var unavailableURLMsg = 'Your URL is invalid or temporarily unavailable.' 13 | var tooManyMsg = 'Please use only one method to upload your SGF file.'; 14 | var noInputMsg = 'Use one of these three methods to upload your SGF file.'; 15 | var noTitleMsg = 'Kifu title is mandatory.' 16 | 17 | 18 | var addInputErrors = function(containerSelector, msg) { 19 | // helper function to create ul.errors 20 | var createErrorsUL = function() { 21 | errors = document.createElement('ul'); 22 | errors.classList.add('errors'); 23 | error = document.createElement('li'); 24 | error.textContent = msg; 25 | errors.appendChild(error); 26 | 27 | return errors; 28 | } 29 | 30 | // add ul to all selected containers 31 | document.querySelectorAll(containerSelector).forEach(function(ctn) { 32 | ctn.appendChild(createErrorsUL()); 33 | }); 34 | } 35 | 36 | var clearInputErrors = function(containerSelector) { 37 | errors = document.querySelectorAll(containerSelector + ' .errors'); 38 | errors.forEach(function(e) { 39 | e.remove(); 40 | }); 41 | } 42 | 43 | // set height of container 44 | var setHeight = function() { 45 | var container = document.querySelector('.container'); 46 | var inputContainer = document.querySelector('#sgf-input'); 47 | container.style.height = window.getComputedStyle(inputContainer).height; 48 | } 49 | 50 | var validateSGF = function(sgfStr) { 51 | // check for empty (whitespace) 52 | if (!/\S/.test(sgfStr)) { 53 | return false; 54 | } 55 | try { 56 | SGF.parse(sgfStr) 57 | } catch(e) { 58 | return false; 59 | } 60 | return true; 61 | } 62 | 63 | var postToServer = function(sgfStr) { 64 | var xhr = new XMLHttpRequest(); 65 | xhr.addEventListener('readystatechange', function(e) { 66 | // post initiated 67 | if (xhr.readyState === 1) { 68 | upload.disabled = true; 69 | // post failed due to bad sgf 70 | } else if (xhr.readyState === 4 && xhr.status === 400) { 71 | addInputErrors('#sgf-url-container', invalidSGFMsg); 72 | upload.disabled = false; 73 | // post success 74 | } else if (xhr.readyState === 4 && xhr.status === 200) { 75 | window.location.replace(JSON.parse(xhr.responseText).redirect); 76 | } 77 | }); 78 | 79 | var data = { 80 | 'sgf': sgfStr, 81 | 'img': createThumbnail(sgfStr, config.tq), 82 | 'title': titleInput.value, 83 | 'description': descriptionInput.value 84 | } 85 | xhr.open('POST', '/upload'); 86 | xhr.setRequestHeader('Content-type', 'application/json'); 87 | xhr.send(JSON.stringify(data)); 88 | } 89 | 90 | var initListeners = function() { 91 | fr.addEventListener('load', function(e) { 92 | if (validateSGF(e.target.result)) { 93 | postToServer(e.target.result); 94 | } else { 95 | addInputErrors('#sgf-file-container', invalidSGFMsg); 96 | } 97 | }); 98 | 99 | urlXHR.addEventListener('readystatechange', function(e) { 100 | // post initiated 101 | if (urlXHR.readyState === 1) { 102 | upload.disabled = true; 103 | } else if (urlXHR.readyState === 4) { 104 | if (urlXHR.status === 200) { 105 | // file successfully retrieved from URL 106 | sgfStr = JSON.parse(this.responseText).sgf; 107 | if (validateSGF(sgfStr)) { 108 | postToServer(sgfStr); 109 | } else { 110 | upload.disabled = false; 111 | addInputErrors('#sgf-url-container', invalidSGFMsg); 112 | } 113 | // file retrieval failed due to unavailable URL 114 | } else if (urlXHR.status === 404) { 115 | upload.disabled = false; 116 | addInputErrors('#sgf-url-container', unavailableURLMsg); 117 | // file retrieval failed due to decode error 118 | } else if (urlXHR.status === 400) { 119 | upload.disabled = false; 120 | addInputErrors('#sgf-url-container', invalidSGFMsg); 121 | } 122 | } 123 | }); 124 | } 125 | 126 | // form validation and trigger proper events 127 | var uploadSubmit = function(e) { 128 | e.preventDefault(); 129 | clearInputErrors('#upload-form'); 130 | var validated = true; 131 | 132 | // check which upload method(s) is being used 133 | var fileActive = fileInput.value !== '' ? 1 : 0; 134 | var textActive = /\S/.test(textInput.value)? 1 : 0; 135 | var urlActive = /\S/.test(urlInput.value) && urlInput.value !== 'http://' ? 1 : 0; 136 | 137 | // check for too many inputs 138 | if (fileActive + textActive + urlActive > 1) { 139 | if (fileActive) { 140 | addInputErrors('#sgf-file-container', tooManyMsg); 141 | } 142 | if (textActive) { 143 | addInputErrors('#sgf-text-container', tooManyMsg); 144 | } 145 | if (urlActive) { 146 | addInputErrors('#sgf-url-container', tooManyMsg); 147 | } 148 | setHeight(); 149 | validated = false;; 150 | } 151 | 152 | // check for no input at all 153 | if (fileActive + textActive + urlActive < 1) { 154 | addInputErrors('#sgf-input .subcontainer', noInputMsg); 155 | setHeight(); 156 | validated = false; 157 | } 158 | 159 | // check if kifu title has been filled out 160 | if (!/\S/.test(titleInput.value)) { 161 | addInputErrors('#kifu-title-container', noTitleMsg); 162 | validated = false; 163 | } 164 | 165 | if (!validated) { 166 | return false; 167 | } 168 | 169 | // read sgf content based on which upload method is used 170 | if (fileActive) { 171 | fr.readAsText(fileInput.files[0]); 172 | } 173 | if (textActive) { 174 | if (validateSGF(textInput.value)) { 175 | postToServer(textInput.value); 176 | } else { 177 | addInputErrors('#sgf-text-container', invalidSGFMsg); 178 | } 179 | } 180 | if (urlActive) { 181 | data = {'url': urlInput.value}; 182 | urlXHR.open('POST', '/get-external-sgf'); 183 | urlXHR.setRequestHeader('Content-type', 'application/json'); 184 | urlXHR.send(JSON.stringify(data)); 185 | } 186 | 187 | return true; 188 | } 189 | 190 | // JS custom file input 191 | var enableFileInput = function() { 192 | // helper function to remove selected file indicator 193 | var removeFileNameLabel = function() { 194 | var fileNameLabel = document.querySelectorAll('.chosen-file-name'); 195 | fileNameLabel.forEach(function(fnl) { 196 | fnl.remove(); 197 | }); 198 | } 199 | 200 | // file label acts as file input button 201 | var fileLabel = document.querySelector('#sgf-file-container label'); 202 | 203 | fileLabel.addEventListener('click', function(e) { 204 | // no file selected 205 | if (fileInput.value === '') { 206 | fileInput.click(); 207 | // click again to deselect the file 208 | } else { 209 | fileInput.value = ''; 210 | removeFileNameLabel(); 211 | clearInputErrors('#sgf-input'); 212 | fileLabel.textContent = 'Pick a file from your computer'; 213 | } 214 | }); 215 | 216 | fileInput.addEventListener('change', function(e) { 217 | clearInputErrors('#sgf-input'); 218 | removeFileNameLabel(); 219 | 220 | // if a new file is chosen 221 | if (this.value !== '') { 222 | // change fileLabel text 223 | fileLabel.textContent = 'Remove selected file'; 224 | // add filename label 225 | var fileNameLabel = document.createElement('label'); 226 | fileNameLabel.classList.add('chosen-file-name'); 227 | fileNameLabel.textContent = this.value.replace(/([^\\]*\\)*/,''); 228 | fileLabel.parentNode.appendChild(fileNameLabel); 229 | } 230 | }); 231 | } 232 | 233 | enableFileInput(); 234 | initListeners(); 235 | upload.addEventListener('click', uploadSubmit); 236 | -------------------------------------------------------------------------------- /app/static/js/board_canvas.js: -------------------------------------------------------------------------------- 1 | // maintain consistency between canvas and driver 2 | var BoardCanvas = function(canvas, ctx, driver) { 3 | this.canvas = canvas; 4 | this.ctx = ctx; 5 | this.driver = driver; 6 | 7 | // set canvas size in pixels 8 | // this is twice as large as it should be for clarity 9 | var px = (config.sz + 1) * config.canvas.sp + config.sz * config.canvas.lw; 10 | this.canvas.width = px; 11 | this.canvas.height = px; 12 | var realPX = config.canvas.px + 'px'; 13 | this.canvas.style.width = realPX; 14 | this.canvas.style.height = realPX; 15 | 16 | // calculate scale factor 17 | this.scale = px / config.canvas.px; 18 | 19 | // first render 20 | this.render(); 21 | }; 22 | 23 | BoardCanvas.prototype.drawBackground = function() { 24 | this.ctx.fillStyle = config.colors.bg; 25 | this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); 26 | }; 27 | 28 | BoardCanvas.prototype.drawGrid = function() { 29 | this.ctx.lineWidth = config.canvas.lw; 30 | this.ctx.strokeStyle = config.colors.line; 31 | 32 | var size = config.sz; 33 | var spacing = config.canvas.sp; 34 | var lw = config.canvas.lw; 35 | var x = spacing, y = spacing; 36 | 37 | // begin drawing 38 | this.ctx.beginPath(); 39 | for (var i = 0; i < config.sz; i++) { 40 | // draw vertical line 41 | this.ctx.moveTo(x + i * (spacing + lw), y); 42 | this.ctx.lineTo(x + i * (spacing + lw), size * (spacing + lw)); 43 | // draw horizontal line 44 | this.ctx.moveTo(x, y + i * (spacing + lw)); 45 | this.ctx.lineTo(size * (spacing + lw), y + i * (spacing + lw)); 46 | } 47 | this.ctx.stroke(); 48 | this.ctx.closePath(); 49 | }; 50 | 51 | BoardCanvas.prototype.drawStarPoints = function() { 52 | this.ctx.fillStyle = config.colors.star; 53 | 54 | var spacing = config.canvas.sp; 55 | var lw = config.canvas.lw; 56 | 57 | this.ctx.beginPath(); 58 | constants.stars.forEach(function(star) { 59 | var x = utils.b2c(star[1], spacing, lw); 60 | var y = utils.b2c(star[0], spacing, lw); 61 | this.ctx.moveTo(x, y); 62 | this.ctx.arc(x, y, spacing * config.canvas.sr, 0, 2 * Math.PI, false); 63 | }, this); 64 | this.ctx.fill(); 65 | this.ctx.closePath(); 66 | }; 67 | 68 | BoardCanvas.prototype.drawStones = function() { 69 | var grid = this.driver.board.grid; 70 | var size = config.sz; 71 | var spacing = config.canvas.sp; 72 | var lw = config.canvas.lw; 73 | 74 | for (var i = 0; i < size; i++) { 75 | for (var j = 0; j < size; j++) { 76 | if (grid[i][j] !== '.') { 77 | this.ctx.beginPath(); 78 | // set stone color 79 | this.ctx.fillStyle = config.colors[grid[i][j]]; 80 | 81 | // find canvas coordinates 82 | var x = utils.b2c(j, spacing, lw); 83 | var y = utils.b2c(i, spacing, lw); 84 | this.ctx.moveTo(x, y); 85 | 86 | // draw stones 87 | this.ctx.arc(x, y, config.canvas.st * spacing, 0, 2 * Math.PI, false); 88 | this.ctx.fill(); 89 | this.ctx.closePath(); 90 | } 91 | } 92 | } 93 | }; 94 | 95 | BoardCanvas.prototype.drawIndicators = function() { 96 | var indicators = this.driver.indicatorLayer; 97 | var size = config.sz; 98 | var spacing = config.canvas.sp; 99 | var lw = config.canvas.lw; 100 | 101 | for (var i = 0; i < size; i++) { 102 | for (var j = 0; j < size; j++) { 103 | if (indicators[i][j] !== '') { 104 | // find canvas coordinates 105 | var x = utils.b2c(j, spacing, lw); 106 | var y = utils.b2c(i, spacing, lw); 107 | this.ctx.moveTo(x, y); 108 | this.ctx.beginPath(); 109 | switch (indicators[i][j]) { 110 | // most recent moves (black or white) 111 | case 'rb': 112 | this.ctx.strokeStyle = config.colors['w']; 113 | this.ctx.lineWidth = config.canvas.mw * lw; 114 | this.ctx.arc(x, y, config.canvas.mk * spacing, 115 | 0, 2 * Math.PI, false); 116 | this.ctx.stroke(); 117 | break; 118 | case 'rw': 119 | this.ctx.strokeStyle = config.colors['b']; 120 | this.ctx.lineWidth = config.canvas.mw * lw; 121 | this.ctx.arc(x, y, config.canvas.mk * spacing, 122 | 0, 2 * Math.PI, false); 123 | this.ctx.stroke(); 124 | break; 125 | // next moves indicators (represented as numbers) 126 | default: 127 | var varNum = indicators[i][j]; 128 | // draw brackground 129 | var squareRadius = config.canvas.nx * spacing; 130 | this.ctx.fillStyle = config.colors.n; 131 | this.ctx.rect(x-squareRadius, y-squareRadius, 2*squareRadius, 2*squareRadius); 132 | this.ctx.fill(); 133 | // draw variation number 134 | if (varNum.length === 1) { 135 | this.ctx.font = '700 30px monospace'; 136 | } else if (varNum.length === 2) { 137 | this.ctx.font = '700 25px monospace'; 138 | // more than 100 variations at a node? probably not gonna happen. 139 | } else { 140 | this.ctx.font = '700 20px monospace'; 141 | } 142 | this.ctx.textAlign = 'center'; 143 | this.ctx.fillStyle = config.colors['w']; 144 | this.ctx.fillText(varNum, x, y+(squareRadius/2)); 145 | break; 146 | console.log('Unknown marker: ' + indicators[i][j]); 147 | } 148 | this.ctx.closePath(); 149 | } 150 | } 151 | } 152 | }; 153 | 154 | BoardCanvas.prototype.drawMarkers = function() { 155 | var markers = this.driver.markerLayer; 156 | var size = config.sz; 157 | var spacing = config.canvas.sp; 158 | var lw = config.canvas.lw; 159 | 160 | for (var i = 0; i < size; i++) { 161 | for (var j = 0; j < size; j++) { 162 | if (markers[i][j] !== '') { 163 | // find canvas coordinates 164 | var x = utils.b2c(j, spacing, lw); 165 | var y = utils.b2c(i, spacing, lw); 166 | this.ctx.moveTo(x, y); 167 | this.ctx.beginPath(); 168 | 169 | this.ctx.strokeStyle = config.colors['mk'][ 170 | this.driver.board.grid[i][j] 171 | ]; 172 | this.ctx.lineWidth = config.canvas.mw * lw; 173 | 174 | switch (markers[i][j]) { 175 | // triangle 176 | case 't': 177 | var p1x = x+1, p1y = y+1 - spacing/2; 178 | var p2x = x+1 + spacing*3/8, p2y = y+1 + spacing/4; 179 | var p3x = x+2 - spacing*Math.sqrt(3)/4, p3y = y+1 + spacing/4; 180 | this.ctx.moveTo(p1x, p1y); 181 | this.ctx.lineTo(p2x, p2y); 182 | this.ctx.moveTo(p2x, p2y); 183 | this.ctx.lineTo(p3x, p3y); 184 | this.ctx.moveTo(p3x, p3y); 185 | this.ctx.lineTo(p1x, p1y); 186 | break; 187 | // square 188 | case 's': 189 | var side = spacing / Math.sqrt(2); 190 | this.ctx.strokeRect(x+1 - side/2, y+1 - side/2, side-2, side-2); 191 | break; 192 | // TODO: draw circile and X 193 | } 194 | 195 | this.ctx.stroke(); 196 | this.ctx.closePath(); 197 | } 198 | } 199 | } 200 | }; 201 | 202 | // render the board with helper functions above 203 | BoardCanvas.prototype.render = function() { 204 | this.drawBackground(); 205 | this.drawGrid(); 206 | this.drawStarPoints(); 207 | this.drawStones(); 208 | this.drawIndicators(); 209 | this.drawMarkers(); 210 | }; 211 | 212 | // render without markers/indicators 213 | BoardCanvas.prototype.simpleRender = function() { 214 | this.drawBackground(); 215 | this.drawGrid(); 216 | this.drawStarPoints(); 217 | this.drawStones(); 218 | } 219 | 220 | // control functions (see gametree.js and driver.js) 221 | // sync canvas with driver 222 | BoardCanvas.prototype.next = function(childIndex) { 223 | if (this.driver.next(childIndex)) { 224 | this.render(); 225 | return true; 226 | } 227 | return false; 228 | }; 229 | 230 | BoardCanvas.prototype.prev = function() { 231 | if (this.driver.prev()) { 232 | this.render(); 233 | return true; 234 | } 235 | return false; 236 | }; 237 | 238 | BoardCanvas.prototype.play = function(row, col) { 239 | if (this.driver.play(row, col)) { 240 | this.render(); 241 | return true; 242 | } 243 | return false; 244 | }; 245 | 246 | BoardCanvas.prototype.pass = function() { 247 | if (this.driver.pass()) { 248 | this.render(); 249 | return true; 250 | } 251 | return false; 252 | }; 253 | 254 | BoardCanvas.prototype.delete = function() { 255 | if (this.driver.delete()) { 256 | this.render(); 257 | return true; 258 | } 259 | return false; 260 | }; 261 | 262 | BoardCanvas.prototype.addStone = function(row, col, stone) { 263 | if (this.driver.addStone(row, col, stone)) { 264 | this.render(); 265 | return true; 266 | } 267 | return false; 268 | }; 269 | 270 | BoardCanvas.prototype.addMarker = function(row, col, marker) { 271 | if (this.driver.addMarker(row, col, marker)) { 272 | this.render(); 273 | return true; 274 | } 275 | return false; 276 | }; 277 | -------------------------------------------------------------------------------- /app/static/js/board.js: -------------------------------------------------------------------------------- 1 | // go board class declaration 2 | var Board = function (size, stars, toPlay) { 3 | this.size = size; // board dimension 4 | this.stars = stars; // star point indices 5 | 6 | this.grid = []; // 2d array representation of board 7 | for (var i = 0; i < size; i++) { 8 | var row = []; 9 | for (var j = 0; j < size; j++) { 10 | // empty location on board represented as '.' 11 | row.push('.'); 12 | } 13 | this.grid.push(row); 14 | } 15 | 16 | this.prevGrid = null; // previous board configuration, used for KO checking 17 | this.toPlay = toPlay? toPlay: 'b'; // black plays first 18 | 19 | // a list of moves played, along with the stones captured by each move 20 | this.history = []; 21 | }; 22 | 23 | // remove all markers from board 24 | Board.prototype.removeMarkers = function() { 25 | for (var i = 0; i < this.size; i++) { 26 | for (var j = 0; j < this.size; j++) { 27 | if (this.grid[i][j] !== 'b' && this.grid[i][j] !== 'w') { 28 | this.grid[i][j] = '.'; 29 | } 30 | } 31 | } 32 | } 33 | 34 | // add stone to board 35 | Board.prototype.add = function(row, col, type) { 36 | if (!this.isValidLocation(row, col)) { 37 | console.log('Invalid location'); 38 | return false 39 | } 40 | 41 | if (constants.st.indexOf(type) === -1) { 42 | console.log('Invalid stone'); 43 | return false; 44 | } 45 | 46 | if (this.grid[row][col] !== '.') { 47 | console.log("Can't add stone at occupied spot"); 48 | return false; 49 | } 50 | 51 | this.grid[row][col] = type; 52 | return true; 53 | }; 54 | 55 | // remove a stone or marker from board 56 | Board.prototype.remove = function(row, col) { 57 | if (!this.isValidLocation(row, col)) { 58 | console.log('Invalid location'); 59 | } else { 60 | this.grid[row][col] = '.'; 61 | } 62 | } 63 | 64 | // check if row, col are valid 65 | Board.prototype.isValidLocation = function(row, col) { 66 | if (row < 0 || row >= this.size) 67 | return false; 68 | if (col < 0 || col >= this.size) 69 | return false; 70 | return true; 71 | }; 72 | 73 | // get a size-by-size array of false 74 | Board.prototype.getFlags = function() { 75 | var flags = []; 76 | for (var i = 0; i < this.size; i++) { 77 | flags.push([]); 78 | for (var j = 0; j < this.size; j++) 79 | flags[i].push(false); 80 | } 81 | return flags; 82 | }; 83 | 84 | // dfs routine to help find chains 85 | Board.prototype.chainAtHelper = function(row, col, color, chain, visitedFlags) { 86 | if (!this.isValidLocation(row, col)) 87 | return 88 | if (this.grid[row][col] !== color) 89 | return 90 | if (visitedFlags[row][col]) 91 | return 92 | 93 | chain.push([row, col]); 94 | visitedFlags[row][col] = true; 95 | 96 | constants.dir.forEach(function(d) { 97 | this.chainAtHelper(row+d[0], col+d[1], color, chain, visitedFlags); 98 | }, this); 99 | }; 100 | 101 | // find a chain of stones of the same color 102 | Board.prototype.chainAt = function(row, col) { 103 | if (!this.isValidLocation(row, col)) 104 | return []; 105 | 106 | var color = this.grid[row][col]; 107 | if (color !== 'b' && color !== 'w') 108 | return []; 109 | 110 | var visitedFlags = this.getFlags(); 111 | var chain = []; 112 | this.chainAtHelper(row, col, color, chain, visitedFlags); 113 | return chain; 114 | }; 115 | 116 | // help count liberty 117 | Board.prototype.libertyHelper = function(row, col, flags) { 118 | if (this.isValidLocation(row, col) && !flags[row][col]) 119 | if (this.grid[row][col] !== 'b' && this.grid[row][col] !== 'w') { 120 | flags[row][col] = true; 121 | return 1; 122 | } 123 | return 0; 124 | }; 125 | 126 | // count the liberty of a stone chain 127 | Board.prototype.countLiberty = function(chain) { 128 | var flags = this.getFlags(); 129 | var lib = 0; 130 | for (var i = 0; i < chain.length; i++) { 131 | var row = chain[i][0]; 132 | var col = chain[i][1]; 133 | if (flags[row][col]) { 134 | console.log('oopsie'); 135 | continue; 136 | } 137 | // if not counted previously then proceed 138 | constants.dir.forEach(function(d) { 139 | lib += this.libertyHelper(row+d[0], col+d[1], flags); 140 | }, this); 141 | } 142 | return lib; 143 | }; 144 | 145 | // return chains (if any) captured by placing stone at row, col 146 | Board.prototype.getCapture = function(row, col, stone) { 147 | if (!this.isValidLocation(row, col)) { 148 | console.error('out of bounds'); 149 | return []; 150 | } 151 | if (this.grid[row][col] === 'b' || this.grid[row][col] === 'w') { 152 | console.error('error checking capture: illegal move at occupied location'); 153 | return []; 154 | } 155 | 156 | var capture = []; 157 | var chain, lib, r, c; 158 | constants.dir.forEach(function(d) { 159 | r = row + d[0]; 160 | c = col + d[1]; 161 | if (this.isValidLocation(r, c)) { 162 | // neighboring location must have stone of opposite color 163 | if (this.grid[r][c] !== '.' && this.grid[r][c] !== stone) { 164 | // check for capture 165 | chain = this.chainAt(r, c); 166 | lib = this.countLiberty(chain); 167 | // if lib = 1, then stone will capture 168 | if (lib == 1) 169 | capture.push(chain); 170 | } 171 | } 172 | }, this); 173 | return capture; 174 | } 175 | 176 | 177 | // playing a move at row, col 178 | Board.prototype.play = function(row, col) { 179 | if (!this.isValidLocation(row, col)) { 180 | console.error('Out of bounds error'); 181 | return false; 182 | } 183 | 184 | if (this.grid[row][col] === 'b' || this.grid[row][col] === 'w') { 185 | console.error('Cannot play on an occupied spot'); 186 | return false; 187 | } 188 | 189 | // construct a copy of the current board state 190 | var gridCopy = []; 191 | var i, j; 192 | for (i = 0; i < this.size; i++) { 193 | var row_copy = []; 194 | for (j = 0; j < this.size; j++) { 195 | row_copy.push(this.grid[i][j]); 196 | } 197 | gridCopy.push(row_copy); 198 | } 199 | 200 | // remove captured stones and place the move 201 | var captures = this.getCapture(row, col, this.toPlay); 202 | var grid = this.grid; 203 | captures.forEach(function(capChain) { 204 | capChain.forEach(function(cap) { 205 | grid[cap[0]][cap[1]] = '.'; 206 | }) 207 | }); 208 | this.grid[row][col] = this.toPlay; 209 | 210 | // suicide move 211 | if (this.countLiberty(this.chainAt(row, col)) === 0) { 212 | this.grid = gridCopy; 213 | console.error('Suicide move is illegal'); 214 | return false; 215 | } 216 | // check for KO 217 | if (this.prevGrid) { 218 | var identicalFlag = true; 219 | for (i = 0; i < this.size; i++) { 220 | for (j = 0; j < this.size; j++) { 221 | if (this.prevGrid[i][j] !== this.grid[i][j]) 222 | identicalFlag = false; 223 | } 224 | } 225 | // KO error 226 | if (identicalFlag) { 227 | this.grid = gridCopy; 228 | console.error('Cannot retake KO immediately'); 229 | return false; 230 | } 231 | } 232 | // legal move 233 | this.history.push({ 234 | 'player': this.toPlay, 235 | 'pos': [row, col], 236 | 'cap': captures 237 | }); 238 | this.prevGrid = gridCopy; 239 | this.toPlay = (this.toPlay === 'b') ? 'w' : 'b'; 240 | return true; 241 | } 242 | 243 | // pass 244 | Board.prototype.pass = function() { 245 | // switch player 246 | this.toPlay = (this.toPlay === 'b') ? 'w' : 'b'; 247 | // add pass entry to history 248 | this.history.push('p'); 249 | } 250 | 251 | // undo the move from grid 252 | Board.prototype.undoHelper = function(move, grid) { 253 | // remove move from grid 254 | grid[move.pos[0]][move.pos[1]] = '.'; 255 | 256 | // restore captured stones 257 | var capColor = move.player === 'b' ? 'w': 'b'; 258 | move.cap.forEach(function(capChain) { 259 | capChain.forEach(function(c) { 260 | grid[c[0]][c[1]] = capColor; 261 | }) 262 | }); 263 | } 264 | 265 | // undo the most recent move 266 | Board.prototype.undo = function() { 267 | // if there is no move to undo 268 | if (this.history.length === 0) { 269 | return; 270 | } 271 | 272 | // remove last move from grid 273 | var lastMove = this.history.pop(); 274 | // if last move is pass 275 | if (lastMove === 'p') { 276 | this.toPlay = (this.toPlay === 'b') ? 'w' : 'b'; 277 | // not pass 278 | } else { 279 | this.undoHelper(lastMove, this.grid); 280 | // reset toPlay 281 | this.toPlay = lastMove.player; 282 | 283 | // restore prevGrid 284 | if (this.history.length === 0) { 285 | this.prevGrid = null; 286 | } else if (this.history[this.history.length - 1] !== 'p') { 287 | this.undoHelper(this.history[this.history.length - 1], this.prevGrid); 288 | } 289 | } 290 | }; 291 | 292 | // print board to page for debugging purposes 293 | Board.prototype.printToPage = function() { 294 | document.open(); 295 | document.write('
');
296 |   for (var i = 0; i < this.size; i++) {
297 |     document.write(this.grid[i].join(''));
298 |     document.write('\n');
299 |   }
300 |   document.write('
'); 301 | document.close(); 302 | }; 303 | -------------------------------------------------------------------------------- /app/static/js/gametree.js: -------------------------------------------------------------------------------- 1 | var GameTree = function(root, nextNodeID) { 2 | // root of the game tree 3 | this.root = root; 4 | 5 | // integer ID used to identify nodes in the game tree 6 | // increments when new node is added 7 | // does NOT decrement when node is deleted 8 | this.nextNodeID = nextNodeID; 9 | 10 | // the current active node in the game tree 11 | this.currentNode = root; 12 | 13 | // all valid next variations 14 | this.nextVar = {}; 15 | this.updateNextVariations(); 16 | 17 | // set first node with valid variations 18 | this.setFirstNode(); 19 | 20 | // read game information 21 | this.gameInfo = { 22 | 'PB': '', // black player 23 | 'BR': '', // black rank 24 | 'PW': '', // white player 25 | 'WR': '', // white rank 26 | 'KM': '', // komi 27 | 'RE': '' // game result 28 | }; 29 | for (var prop in this.gameInfo) { 30 | this.gameInfo[prop] = this.findValueByProp(prop); 31 | } 32 | 33 | // set to true when adding stones 34 | // group all stones added in one session into one node 35 | this.addStoneActive = false; 36 | }; 37 | 38 | // update the next valid variations 39 | // valid variations contain B, W (pass included), AB or AW 40 | // the next valid variations will be visualized on board and control 41 | GameTree.prototype.updateNextVariations = function() { 42 | this.nextVar = { 43 | // only 1 pass variation is possible 44 | // nextVar.pass refers to the index of pass in children[] 45 | 'pass': -1, 46 | 'play': [], 47 | 'add': [] 48 | }; 49 | 50 | for (var i = 0; i < this.currentNode.children.length; i++) { 51 | var child = this.currentNode.children[i]; 52 | for (var j = 0; j < child.actions.length; j++) { 53 | var prop = child.actions[j].prop; 54 | var val = child.actions[j].value; 55 | if (prop === 'B' || prop === 'W') { 56 | // a pass variation, anything other than -1 indicates that there is a pass 57 | if (val === '' || val === 'tt') { 58 | this.nextVar.pass = i; 59 | // a play variation contains play location and index in children[] 60 | } else { 61 | this.nextVar.play.push({ 62 | 'row': utils.l2n(val[1]), 63 | 'col': utils.l2n(val[0]), 64 | 'index': i 65 | }); 66 | } 67 | break; 68 | } else if (prop === 'AB' || prop === 'AW') { 69 | // an add variation simply contains its index in children[] 70 | this.nextVar.add.push({ 71 | 'index': i 72 | }); 73 | break; 74 | } 75 | } 76 | } 77 | }; 78 | 79 | // check if there exists a valid next variation 80 | GameTree.prototype.hasValidNextVar = function() { 81 | var nv = this.nextVar; 82 | if (nv.pass !== -1 || nv.play.length || nv.add.length) { 83 | return true; 84 | } 85 | return false; 86 | } 87 | 88 | // set and go to the first node that has a valid nextVar 89 | GameTree.prototype.setFirstNode = function() { 90 | while (!this.atEnd() && !this.hasValidNextVar()) { 91 | this.next(); 92 | } 93 | this.firstNode = this.currentNode; 94 | } 95 | 96 | // find value of a given property using DFS (assume prop is unique) 97 | // used to extract game information 98 | GameTree.prototype.findValueByProp = function(prop) { 99 | var value = ''; 100 | 101 | // dfs helper 102 | var helper = function(node) { 103 | for (var i = 0; i < node.actions.length; i++) { 104 | if (node.actions[i].prop === prop) { 105 | value = node.actions[i].value; 106 | break; 107 | } 108 | } 109 | 110 | if (value === '') { 111 | node.children.forEach(function(child) { 112 | if (value === '') { 113 | helper(child); 114 | } 115 | }); 116 | } 117 | } 118 | 119 | helper(this.root); 120 | return value; 121 | }; 122 | 123 | // check if at the end of a particular variation 124 | GameTree.prototype.atEnd = function() { 125 | return this.currentNode.children.length === 0; 126 | }; 127 | 128 | // check if at beginning of game tree 129 | GameTree.prototype.atBeginning = function() { 130 | return this.currentNode === this.root; 131 | }; 132 | 133 | // check if at the first (valid) node of the game tree 134 | GameTree.prototype.atFirstNode = function() { 135 | return this.currentNode === this.firstNode; 136 | } 137 | 138 | // advance to the next node in game tree 139 | GameTree.prototype.next = function(childIndex) { 140 | if (this.atEnd()) { 141 | console.error('The end has been reached'); 142 | return false; 143 | } 144 | 145 | childIndex = childIndex === undefined ? 0: childIndex; 146 | if (childIndex && childIndex >= this.currentNode.children.length) { 147 | console.error('Variation does not exist'); 148 | return false; 149 | } 150 | 151 | this.currentNode = this.currentNode.children[childIndex]; 152 | this.updateNextVariations(); 153 | this.addStoneActive = false; 154 | return true; 155 | }; 156 | 157 | // move to the previous node in game tree 158 | GameTree.prototype.prev = function() { 159 | if (this.atBeginning()) { 160 | console.error('Already at the beginning'); 161 | return false; 162 | } 163 | this.currentNode = this.currentNode.parent; 164 | this.updateNextVariations(); 165 | this.addStoneActive = false; 166 | return true; 167 | }; 168 | 169 | // add a play node to game tree and advance to it 170 | GameTree.prototype.play = function(player, row, col) { 171 | // construct play node 172 | var playNode = new Node(this.currentNode); 173 | playNode.addAction( 174 | player, 175 | utils.n2l(col) + utils.n2l(row) 176 | ); 177 | playNode.id = this.nextNodeID; 178 | this.nextNodeID++; 179 | 180 | // attach play node 181 | this.currentNode.addChild(playNode); 182 | this.currentNode = playNode; 183 | this.updateNextVariations(); 184 | this.addStoneActive = false; 185 | return true; 186 | }; 187 | 188 | // delete the current node 189 | GameTree.prototype.delete = function() { 190 | // cannot delete root of game tree 191 | if (this.currentNode === this.root) { 192 | return false; 193 | } 194 | 195 | var node = this.currentNode; 196 | this.currentNode = this.currentNode.parent; 197 | var children = this.currentNode.children; 198 | 199 | // detach node 200 | node.parent = null; 201 | var i; 202 | for (i = 0; i < children.length; i++) { 203 | if (children[i] === node) { 204 | break; 205 | } 206 | } 207 | children.splice(i, 1); 208 | 209 | this.updateNextVariations(); 210 | this.addStoneActive = false; 211 | return true; 212 | }; 213 | 214 | // pass (skip a turn) 215 | GameTree.prototype.pass = function(player) { 216 | if (this.nextVar.pass !== -1) { 217 | this.next(this.nextVar.pass); 218 | } else { 219 | // construct pass node 220 | var passNode = new Node(this.currentNode); 221 | passNode.addAction( 222 | player, 223 | '' 224 | ); 225 | passNode.id = this.nextNodeID; 226 | this.nextNodeID++; 227 | 228 | // attach pass node 229 | this.currentNode.addChild(passNode); 230 | this.currentNode = passNode; 231 | this.updateNextVariations(); 232 | } 233 | this.addStoneActive = false; 234 | // return a boolean to futureproof possible pass failures 235 | // current all passes should succeed 236 | return true; 237 | }; 238 | 239 | // add black and white stones 240 | GameTree.prototype.addStone = function(row, col, stone) { 241 | // check for unknown stone types 242 | if (constants.st.indexOf(stone) === -1) { 243 | console.log('unknown stone: ' + stone); 244 | return false; 245 | } 246 | 247 | // continue adding stones 248 | if (this.addStoneActive) { 249 | this.currentNode.addAction( 250 | 'A' + stone.toUpperCase(), 251 | utils.n2l(col) + utils.n2l(row) 252 | ); 253 | // start new stone-adding session 254 | } else { 255 | this.addStoneActive = true; 256 | // create new node 257 | var addNode = new Node(this.currentNode); 258 | addNode.addAction( 259 | 'A' + stone.toUpperCase(), 260 | utils.n2l(col) + utils.n2l(row) 261 | ); 262 | addNode.id = this.nextNodeID; 263 | this.nextNodeID++; 264 | // attach add node 265 | this.currentNode.addChild(addNode); 266 | this.currentNode = addNode; 267 | this.updateNextVariations(); 268 | } 269 | 270 | return true; 271 | }; 272 | 273 | // add markers 274 | GameTree.prototype.addMarker = function(row, col, marker) { 275 | // check for unknown marker types 276 | if (!constants.mk.hasOwnProperty(marker)) { 277 | return false; 278 | } 279 | markerSGF = constants.mk[marker]; 280 | 281 | // markers are always added to the current node 282 | this.currentNode.addAction( 283 | markerSGF, 284 | utils.n2l(col) + utils.n2l(row) 285 | ); 286 | 287 | return true; 288 | }; 289 | 290 | GameTree.prototype.infoString = function() { 291 | return { 292 | 'blackPlayer': this.gameInfo.PB? this.gameInfo.PB: 'Anonymous', 293 | 'whitePlayer': this.gameInfo.PW? this.gameInfo.PW: 'Anonymous', 294 | 'blackRank': this.gameInfo.BR? this.gameInfo.BR: '?', 295 | 'whiteRank': this.gameInfo.WR? this.gameInfo.WR: '?', 296 | 'komi': this.gameInfo.KM? this.gameInfo.KM: '?', 297 | 'result': this.gameInfo.RE? this.gameInfo.RE: '?' 298 | }; 299 | }; 300 | -------------------------------------------------------------------------------- /app/static/js/sgf.js: -------------------------------------------------------------------------------- 1 | // todo: prevent possible stack overflow errors 2 | 3 | var SGF = (function() { 4 | var maxNodeID = -1; 5 | 6 | // a helper function that gets index of first ] that is not 7 | // escaped by a \ before it 8 | var noEscapeBracketIndex = function(s, start) { 9 | nebi = s.indexOf(']', start); 10 | if (nebi === -1) { 11 | return -1; 12 | } 13 | 14 | while (nebi !== -1 && s[nebi-1] === '\\') { 15 | start = nebi + 1; 16 | nebi = s.indexOf(']', start); 17 | } 18 | return nebi; 19 | }; 20 | 21 | // parse a string containing actions into a list 22 | var parseActions = function(actionsStr) { 23 | var actions = []; 24 | var start = 0; 25 | // var bracketIndex = actionsStr.indexOf(']', start); 26 | var bracketIndex = noEscapeBracketIndex(actionsStr, start); 27 | 28 | // handle cases where one action is executed many times 29 | var lastActionProp = ''; 30 | 31 | while (bracketIndex !== -1) { 32 | var i = actionsStr.indexOf('[', start); 33 | var prop = actionsStr.substring(start, i).trim().toUpperCase(); 34 | var value = actionsStr.substring(i+1, bracketIndex).trim(); 35 | 36 | actions.push( 37 | { 38 | 'prop': prop === '' ? lastActionProp : prop, 39 | 'value': value 40 | } 41 | ); 42 | 43 | lastActionProp = prop === '' ? lastActionProp : prop; 44 | start = bracketIndex + 1; 45 | // bracketIndex = actionsStr.indexOf(']', start); 46 | bracketIndex = noEscapeBracketIndex(actionsStr, start); 47 | } 48 | 49 | return actions; 50 | } 51 | 52 | // Parse sgf string containing only 1 variation 53 | // Square brackets escaping is not enabled 54 | var parseVar = function(root, sgfStr) { 55 | var parent = root; 56 | // var nodeStrList = sgfStr.split(';'); 57 | var nodeStrList = semValidSplit(sgfStr, ';'); 58 | 59 | // i starts at 1 because the split list starts 60 | // with the empty string 61 | for (var i = 1; i < nodeStrList.length; i++) { 62 | var node = new Node(parent); 63 | node.actions = parseActions(nodeStrList[i]); 64 | // parse possible node id represented as an action 65 | for (var j = 0; j < node.actions.length; j++) { 66 | var action = node.actions[j]; 67 | // id is present 68 | if (action.prop === 'ID') { 69 | var id = Number(action.value); 70 | if (Number.isNaN(id) || id < 0) { 71 | throw new exceptions.ParsingError(2, 'Invalid id: ' + action.value); 72 | // id is valid 73 | } else { 74 | // add id to node 75 | node.id = id; 76 | // update maxNodeID 77 | maxNodeID = node.id > maxNodeID? node.id: maxNodeID; 78 | // remove id from node.actions 79 | node.actions.splice(j, 1); 80 | break; 81 | } 82 | } 83 | } 84 | parent.addChild(node); 85 | parent = node; 86 | } 87 | 88 | // this is the last node in the game tree 89 | return parent; 90 | } 91 | 92 | // check if sgfStr[i] is semantically valid 93 | // that is, it is not enclosed within [] 94 | var isSemValid = function(sgfStr, i) { 95 | var iLeft = iRight = i; 96 | // go left and look for '[' 97 | // if found, '[' must occur before ']' 98 | while (iLeft >= 0) { 99 | if (sgfStr[iLeft] === '[') 100 | break; 101 | // ']' occurs before '[' 102 | if (sgfStr[iLeft] === ']') 103 | return true; 104 | // '[' is not found 105 | if (iLeft === 0) 106 | return true; 107 | iLeft--; 108 | } 109 | // go right and look for ']' 110 | // if found, ']' must occur before '[' 111 | while (iRight < sgfStr.length) { 112 | if (sgfStr[iRight] === ']') 113 | break; 114 | // '[' occurs before ']' 115 | if (sgfStr[iRight] === '[') 116 | return true; 117 | // ']' is not found 118 | if (iRight === sgfStr.length - 1) 119 | return true; 120 | iRight++; 121 | } 122 | 123 | return false; 124 | } 125 | 126 | // split a string using only semantically valid delimiters 127 | var semValidSplit = function(s, delim) { 128 | // first find indices of all occurences of delim in s 129 | delimIndices = []; 130 | for (var i = 0; i < s.length; i++) { 131 | if (s[i] === delim) { 132 | delimIndices.push(i); 133 | } 134 | } 135 | 136 | // remove semantically invalid delim indices 137 | var validDelimIndices = delimIndices.filter(function(i) { 138 | return isSemValid(s, i); 139 | }); 140 | 141 | // finally, create the split list 142 | if (validDelimIndices.length === 0) { 143 | var splitList = [s]; 144 | } else { 145 | var splitList = []; 146 | var start = 0; 147 | validDelimIndices.forEach(function(vdi) { 148 | splitList.push(s.substring(start, vdi)); 149 | start = vdi + 1; 150 | }); 151 | splitList.push(s.substring(start, s.length)); 152 | } 153 | 154 | return splitList; 155 | } 156 | 157 | // return the index of matching close parenthesis 158 | // only semantically valid parentheses are considered 159 | var findValidPrtsMatch = function(sgfStr, i) { 160 | if (sgfStr[i] !== '(' || !isSemValid(sgfStr, i)) { 161 | throw new exceptions.ParsingError(0, 'Invalid Character: ' + sgfStr[i]); 162 | return -2; 163 | } 164 | 165 | var cnt = 0; 166 | while (i < sgfStr.length) { 167 | if (sgfStr[i] === '(' && isSemValid(sgfStr, i)) { 168 | cnt++; 169 | } else if (sgfStr[i] === ')' && isSemValid(sgfStr, i)) { 170 | cnt--; 171 | } 172 | if (cnt === 0) { 173 | return i; 174 | } 175 | i++; 176 | } 177 | 178 | return -1; 179 | } 180 | 181 | // find the end index of variation beginning at i 182 | var findVarEndIdx = function(sgfStr, i) { 183 | // find the first valid '(' or ')' appearing 184 | // after index i 185 | while (i < sgfStr.length) { 186 | if (sgfStr[i] === '(' || sgfStr[i] === ')') { 187 | if (isSemValid(sgfStr, i)) { 188 | return i; 189 | } 190 | } 191 | i++; 192 | } 193 | return -1; 194 | } 195 | 196 | // recursively parse sgf string and anchor them at root 197 | var parseHelper = function(sgfStr, root, i) { 198 | var parent = root; 199 | while (i < sgfStr.length) { 200 | // if whitespace 201 | if (/\s/.test(sgfStr[i])) { 202 | i++; 203 | // if close parenthesis 204 | } else if (sgfStr[i] === ')') { 205 | i++; 206 | } else if (sgfStr[i] === ';') { 207 | // find the end index of current variation 208 | var end = findVarEndIdx(sgfStr, i); 209 | if (end === -1) { 210 | throw new exceptions.ParsingError(1, 'Invalid SGF String'); 211 | } 212 | // where to go after parsing current variation 213 | var next = sgfStr[end] === '(' ? end : end+1; 214 | // parse the current variation and set 215 | // parent to the last node of the variation 216 | parent = parseVar(parent, sgfStr.substring(i, end)); 217 | // move pointer i 218 | i = next; 219 | } else if (sgfStr[i] === '(') { 220 | // find the matching close parenthesis 221 | var end = findValidPrtsMatch(sgfStr, i); 222 | if (end === -1) { 223 | throw new exceptions.ParsingError(1, 'Invalid SGF String'); 224 | } 225 | // recursively parse the subvariation 226 | // delete the open parenthesis but keep the close one 227 | parseHelper(sgfStr.substring(i+1, end+1), parent, 0); 228 | i = end + 1; 229 | } else { 230 | throw new exceptions.ParsingError(1, 'Invalid SGF String'); 231 | break; 232 | } 233 | } 234 | } 235 | 236 | // add id to nodes in a game tree 237 | // return the next node's id shoud be 238 | var addID = function(root) { 239 | var id = 0; 240 | var helper = function(root) { 241 | root.id = id++; 242 | root.children.forEach(function(child) { 243 | helper(child); 244 | }); 245 | }; 246 | 247 | helper(root); 248 | return id; 249 | } 250 | 251 | // parse sgf string and return a game tree 252 | var parse = function(sgfStr) { 253 | var root = new Node(null); 254 | parseHelper(sgfStr, root, 0); 255 | // if parsing new kifu (without id tag) 256 | if (maxNodeID === -1) { 257 | // reset maxNodeID 258 | maxNodeID = -1; 259 | return new GameTree(root, addID(root)); 260 | } 261 | // parsing kifu retrieved from database 262 | // reset maxNodeID 263 | var maxNodeIDCopy = maxNodeID; 264 | maxNodeID = -1; 265 | return new GameTree(root, maxNodeIDCopy+1); 266 | } 267 | 268 | // convert a game tree into a SGF string 269 | var print = function(root) { 270 | var sgfStr = ''; 271 | 272 | var helper = function(node) { 273 | // each variation starts with ( 274 | sgfStr += '('; 275 | // print actions 276 | node.actions.forEach(function(action, i) { 277 | if (i === 0) { 278 | sgfStr += ';'; 279 | } 280 | sgfStr += action.prop + '[' + action.value + ']'; 281 | }); 282 | // print id 283 | if (node.id !== -1) { 284 | // in case the node has no actions 285 | if (node.actions.length === 0) { 286 | sgfStr += ';'; 287 | } 288 | sgfStr += 'ID' + '[' + node.id + ']'; 289 | } 290 | while (node.children.length === 1) { 291 | node = node.children[0]; 292 | node.actions.forEach(function(action, i) { 293 | if (i === 0) { 294 | sgfStr += ';'; 295 | } 296 | sgfStr += action.prop + '[' + action.value + ']'; 297 | }); 298 | // print id 299 | if (node.id !== -1) { 300 | // in case the node has no actions 301 | if (node.actions.length === 0) { 302 | sgfStr += ';'; 303 | } 304 | sgfStr += 'ID' + '[' + node.id + ']'; 305 | } 306 | }; 307 | // more than one branch 308 | node.children.forEach(function(child) { 309 | helper(child); 310 | }); 311 | sgfStr += ')'; 312 | } 313 | 314 | helper(root); 315 | return sgfStr; 316 | } 317 | 318 | return { 319 | parse: parse, 320 | print: print 321 | }; 322 | })(); 323 | -------------------------------------------------------------------------------- /app/sgf.py: -------------------------------------------------------------------------------- 1 | # the Node and SGF classes are just a re-write of sgf.js 2 | class Node: 3 | def __init__(self, parent): 4 | self.id = -1 5 | self.parent = parent 6 | self.children = [] 7 | self.actions = [] 8 | 9 | def add_child(self, child): 10 | self.children.append(child) 11 | 12 | def add_action(self, prop, val): 13 | self.actions.append({ 14 | 'prop': prop, 15 | 'value': val 16 | }) 17 | 18 | class SGF: 19 | def __init__(self): 20 | self.max_node_id = -1 21 | 22 | def __no_escape_bracket_index(self, s, start): 23 | nebi = s.find(']', start) 24 | if nebi == -1: 25 | return -1 26 | 27 | while nebi != -1 and s[nebi-1] == '\\': 28 | start = nebi + 1 29 | nebi = s.find(']', start) 30 | 31 | return nebi 32 | 33 | def __parse_actions(self, action_str): 34 | actions = [] 35 | start = 0 36 | # bracket_index = action_str.find(']', start) 37 | bracket_index = self.__no_escape_bracket_index(action_str, start) 38 | last_action_prop = '' 39 | 40 | while bracket_index != -1: 41 | i = action_str.find('[', start) 42 | prop = action_str[start: i].strip().upper() 43 | value = action_str[i+1: bracket_index].strip() 44 | actions.append({ 45 | 'prop': last_action_prop if prop == '' else prop, 46 | 'value': value 47 | }) 48 | 49 | last_action_prop = last_action_prop if prop == '' else prop 50 | start = bracket_index + 1 51 | # bracket_index = action_str.find(']', start) 52 | bracket_index = self.__no_escape_bracket_index(action_str, start) 53 | 54 | return actions 55 | 56 | def __parse_var(self, root, sgf_str): 57 | parent = root 58 | node_str_list = self.__sem_valid_split(sgf_str, ';') 59 | for i in range(1, len(node_str_list)): 60 | node = Node(parent) 61 | node.actions = self.__parse_actions(node_str_list[i]) 62 | # handle id stored as an action 63 | for j in range(0, len(node.actions)): 64 | action = node.actions[j] 65 | if (action['prop'] == 'ID'): 66 | id = int(action['value']) # exception could be thrown 67 | if id < 0: 68 | raise ValueError('Invalid ID: ' + id) 69 | node.id = id 70 | if id > self.max_node_id: 71 | self.max_node_id = id 72 | del node.actions[j] 73 | break 74 | parent.add_child(node) 75 | parent = node 76 | 77 | return parent 78 | 79 | def __is_sem_valid(self, sgf_str, i): 80 | i_left = i 81 | i_right = i 82 | while i_left >= 0: 83 | if sgf_str[i_left] == '[': 84 | break 85 | if sgf_str[i_left] == ']': 86 | return True 87 | if i_left == 0: 88 | return True 89 | i_left -= 1 90 | while i_right < len(sgf_str): 91 | if sgf_str[i_right] == ']': 92 | break 93 | if sgf_str[i_right] == '[': 94 | return True 95 | if i_right == len(sgf_str) - 1: 96 | return True 97 | i_right += 1 98 | 99 | return False 100 | 101 | def __sem_valid_split(self, s, delim): 102 | delim_indices = [] 103 | for i in range(len(s)): 104 | if s[i] == delim: 105 | delim_indices.append(i) 106 | 107 | valid_delim_indices = list(filter(lambda i: self.__is_sem_valid(s, i), delim_indices)) 108 | 109 | if len(valid_delim_indices) == 0: 110 | split_list = [s] 111 | else: 112 | split_list = [] 113 | start = 0 114 | for vdi in valid_delim_indices: 115 | split_list.append(s[start: vdi]) 116 | start = vdi + 1 117 | split_list.append(s[start:]) 118 | 119 | return split_list 120 | 121 | def __find_valid_prts_match(self, sgf_str, i): 122 | if sgf_str[i] != '(': 123 | raise ValueError('Matching parenthesis with other characters: ' + sgf_str[i]) 124 | if not self.__is_sem_valid(sgf_str, i): 125 | raise TypeError('Parenthesis at index is not semantically valid') 126 | cnt = 0 127 | while i < len(sgf_str): 128 | if sgf_str[i] == '(' and self.__is_sem_valid(sgf_str, i): 129 | cnt += 1 130 | elif sgf_str[i] == ')' and self.__is_sem_valid(sgf_str, i): 131 | cnt -= 1 132 | if cnt == 0: 133 | return i 134 | i += 1 135 | 136 | return -1 137 | 138 | def __find_var_end_idx(self, sgf_str, i): 139 | while i < len(sgf_str): 140 | if sgf_str[i] == '(' or sgf_str[i] == ')': 141 | if self.__is_sem_valid(sgf_str, i): 142 | return i 143 | i += 1 144 | 145 | return -1 146 | 147 | def __parse_helper(self, sgf_str, root, i): 148 | parent = root 149 | while i < len(sgf_str): 150 | if sgf_str[i].isspace(): 151 | i += 1 152 | elif sgf_str[i] == ')': 153 | i += 1 154 | elif sgf_str[i] == ';': 155 | end = self.__find_var_end_idx(sgf_str, i) 156 | if end == -1: 157 | raise ValueError('Invalid SGF String') 158 | next = end if sgf_str[end] == '(' else (end+1) 159 | parent = self.__parse_var(parent, sgf_str[i:end]) 160 | i = next 161 | elif sgf_str[i] == '(': 162 | end = self.__find_valid_prts_match(sgf_str, i) 163 | if end == -1: 164 | raise ValueError('Invalid SGF String') 165 | self.__parse_helper(sgf_str[i+1:end+1], parent, 0) 166 | i = end + 1 167 | else: 168 | raise ValueError('Invalid SGF String') 169 | 170 | def add_id(self, root): 171 | id = 0 172 | 173 | def helper(root): 174 | nonlocal id 175 | root.id = id 176 | id += 1 177 | for child in root.children: 178 | helper(child) 179 | 180 | helper(root) 181 | return id 182 | 183 | def parse(self, sgf_str): 184 | root = Node(None) 185 | self.__parse_helper(sgf_str, root, 0) 186 | 187 | # if parsing a new kifu (ID tags not added yet) 188 | if (self.max_node_id == -1): 189 | # reset max_node_id 190 | self.max_node_id = -1 191 | self.add_id(root) 192 | 193 | self.max_node_id = -1 194 | return root 195 | 196 | def print(self, root): 197 | sgf_str = '' 198 | 199 | def helper(node): 200 | nonlocal sgf_str 201 | # each variation starts with ( 202 | sgf_str += '(' 203 | # print actions 204 | for i in range(len(node.actions)): 205 | action = node.actions[i] 206 | if i == 0: 207 | sgf_str += ';' 208 | sgf_str += action['prop'] + '[' + action['value'] + ']' 209 | # print id 210 | if node.id != -1: 211 | # in case the node has no actions 212 | if len(node.actions) == 0: 213 | sgf_str += ';' 214 | sgf_str += 'ID' + '[' + str(node.id) + ']' 215 | # as long as there is only one variation 216 | while len(node.children) == 1: 217 | node = node.children[0] 218 | # print actions (see above) 219 | for i in range(len(node.actions)): 220 | action = node.actions[i] 221 | if i == 0: 222 | sgf_str += ';' 223 | sgf_str += action['prop'] + '[' + action['value'] + ']' 224 | # print id (see above) 225 | if node.id != -1: 226 | # in case the node has no actions 227 | if len(node.actions) == 0: 228 | sgf_str += ';' 229 | sgf_str += 'ID' + '[' + str(node.id) + ']' 230 | # more than one branch 231 | for child in node.children: 232 | helper(child) 233 | # close off the variation 234 | sgf_str += ')' 235 | 236 | helper(root) 237 | return sgf_str 238 | 239 | # check if the sgf_str is syntactically valid 240 | def validate_sgf(sgf_str): 241 | sgf = SGF() 242 | try: 243 | root = sgf.parse(sgf_str) 244 | return True 245 | except Exception as e: 246 | print(e) 247 | return False 248 | 249 | # helper function to verify that one node is a "sub_node" of another node 250 | def validate_sub_node(node, sub_node): 251 | # must have the same ID 252 | if node.id != sub_node.id: 253 | return False 254 | # node must contain all of sub_node's actions 255 | if len(node.actions) < len(sub_node.actions): 256 | return False 257 | for sub_action in sub_node.actions: 258 | found = False 259 | for action in node.actions: 260 | if action['prop'] == sub_action['prop'] and action['value'] == sub_action['value']: 261 | found = True 262 | break 263 | if not found: 264 | return False 265 | # validation success 266 | return True 267 | 268 | # sgf_str should contain the entire sub_sgf_str 269 | # with only leaf nodes or leaf subtrees added 270 | def validate_sub_sgf(sgf_str, sub_sgf_str): 271 | # helper dfs routine 272 | def dfs(root, sub_root): 273 | # check if root contains all of sub_root's children 274 | for sub_child in sub_root.children: 275 | found = False 276 | for child in root.children: 277 | if validate_sub_node(child, sub_child): 278 | found = True 279 | # recursively validate the child, subchild pair 280 | if not dfs(child, sub_child): 281 | return False 282 | break 283 | if not found: 284 | return False 285 | # validation success 286 | return True 287 | 288 | # parse both sgf strings 289 | # if error during parsing, just return false 290 | sgf = SGF() 291 | try: 292 | root = sgf.parse(sgf_str) 293 | sub_root = sgf.parse(sub_sgf_str) 294 | except Exception as e: 295 | print(e) 296 | return False 297 | 298 | return dfs(root, sub_root) 299 | 300 | # read information stored in SGF file (e.g. player, rank, result) 301 | # assumes that sgf_str is valid 302 | def get_sgf_info(sgf_str): 303 | 304 | # helper function that finds the value of a property 305 | def find_value_by_prop(root, prop): 306 | value = '' 307 | 308 | # dfs helper 309 | def helper(node): 310 | nonlocal value 311 | for i in range(len(node.actions)): 312 | if node.actions[i]['prop'] == prop: 313 | value = node.actions[i]['value'] 314 | break 315 | # if value not found, recurse 316 | if value == '': 317 | for child in node.children: 318 | if value == '': 319 | helper(child) 320 | 321 | # start from root 322 | helper(root) 323 | return value 324 | 325 | sgf_info = { 326 | 'PB': '', # black player 327 | 'BR': '', # black rank 328 | 'PW': '', # white player 329 | 'WR': '', # white rank 330 | 'KM': '', # komi 331 | 'RE': '' # game result 332 | } 333 | 334 | root = SGF().parse(sgf_str) 335 | for prop in sgf_info: 336 | sgf_info[prop] = find_value_by_prop(root, prop) 337 | 338 | return sgf_info 339 | 340 | # standardize SGF input by parsing and printing 341 | # assumes that sgf_str is valid 342 | def standardize_sgf(sgf_str): 343 | sgf = SGF() 344 | return sgf.print(sgf.parse(sgf_str)) 345 | -------------------------------------------------------------------------------- /app/static/js/driver.js: -------------------------------------------------------------------------------- 1 | // maintains consistency between board and game tree 2 | var Driver = function(sgfStr) { 3 | this.board = new Board(config.sz, constants.stars); 4 | this.gameTree = SGF.parse(sgfStr); 5 | 6 | // a layer of indicators to be drawn on top of the board 7 | this.indicatorLayer = []; // most recent move, next move 8 | // a layer of markers to be drawn on top of the board 9 | this.markerLayer = []; // triangle, square, etc. 10 | 11 | // note: each board position can have at most 1 indicator AND 1 marker 12 | 13 | for (var i = 0; i < config.sz; i++) { 14 | var iRow = []; 15 | var mRow = []; 16 | for (var j = 0; j < config.sz; j++) { 17 | iRow.push(''); 18 | mRow.push(''); 19 | } 20 | this.indicatorLayer.push(iRow); 21 | this.markerLayer.push(mRow); 22 | } 23 | 24 | this.updateIndicatorLayer(); 25 | }; 26 | 27 | // helper function to clear a layer 28 | // also returns a copy of the layer before it is cleared 29 | Driver.prototype.clearLayer = function(layer) { 30 | var copy = []; 31 | for (var i = 0; i < config.sz; i++) { 32 | var row = [] 33 | for (var j = 0; j < config.sz; j++) { 34 | row.push(layer[i][j]); 35 | layer[i][j] = ''; 36 | } 37 | copy.push(row); 38 | } 39 | 40 | return copy; 41 | } 42 | 43 | // update indicator layer 44 | // called when board/gameTree state changes 45 | Driver.prototype.updateIndicatorLayer = function() { 46 | // clear the old layer first 47 | this.clearLayer(this.indicatorLayer); 48 | 49 | // next play variations 50 | // represented as numbers (e.g. first variation = 1) 51 | var plays = this.gameTree.nextVar.play; 52 | for (var i = 0; i < plays.length; i++) { 53 | this.indicatorLayer[plays[i].row][plays[i].col] = (i+1).toString(); 54 | } 55 | 56 | // most recent move 57 | var history = this.board.history; 58 | if (history.length > 0) { 59 | // skip all passes 60 | var i = history.length - 1; 61 | while (i >= 0 && history[i] === 'p') { 62 | i--; 63 | } 64 | // if there exists a most recent move 65 | if (i >= 0) { 66 | var row = history[i].pos[0]; 67 | var col = history[i].pos[1]; 68 | this.indicatorLayer[row][col] = 'r' + history[i].player; 69 | } 70 | } 71 | }; 72 | 73 | // update marker layer 74 | Driver.prototype.updateMarkerLayer = function() { 75 | this.clearLayer(this.markerLayer); 76 | this.gameTree.currentNode.actions.forEach(function(action) { 77 | if (Object.keys(constants.mkSGF).indexOf(action.prop) !== -1) { 78 | // add multiple markers 79 | if (action.value.indexOf(':') !== -1) { 80 | var pos = action.value.split(':'); 81 | var row1 = utils.l2n(pos[0][1]), col1 = utils.l2n(pos[0][0]); 82 | var row2 = utils.l2n(pos[1][1]), col2 = utils.l2n(pos[1][0]); 83 | for (var i = row1; i <= row2; i++) { 84 | for (var j = col1; j <= col2; j++) { 85 | this.markerLayer[i][j] = constants.mkSGF[action.prop]; 86 | } 87 | } 88 | // add single marker 89 | } else { 90 | var row = utils.l2n(action.value[1]); 91 | var col = utils.l2n(action.value[0]); 92 | this.markerLayer[row][col] = constants.mkSGF[action.prop]; 93 | } 94 | } 95 | }, this); 96 | }; 97 | 98 | // helper function that executes an action 99 | Driver.prototype.execAction = function(action) { 100 | switch(action.prop) { 101 | // add black and white stones 102 | case 'AB': 103 | case 'AW': 104 | var stone = action.prop === 'AB' ? 'b': 'w'; 105 | // add multiple stones 106 | if (action.value.indexOf(':') !== -1) { 107 | var pos = action.value.split(':'); 108 | var row1 = utils.l2n(pos[0][1]), col1 = utils.l2n(pos[0][0]); 109 | var row2 = utils.l2n(pos[1][1]), col2 = utils.l2n(pos[1][0]); 110 | for (var i = row1; i <= row2; i++) { 111 | for (var j = col1; j <= col2; j++) { 112 | this.board.add(i, j, stone); 113 | } 114 | } 115 | // add single stone 116 | } else { 117 | var row = utils.l2n(action.value[1]); 118 | var col = utils.l2n(action.value[0]); 119 | this.board.add(row, col, stone); 120 | } 121 | break; 122 | // black or white plays 123 | case 'B': 124 | case 'W': 125 | if (action.value === '' || action.value === 'tt') { 126 | this.board.pass(); 127 | } else { 128 | if (this.board.toPlay.toUpperCase() !== action.prop) { 129 | // player mismatch could happen - for instance, white plays the first move in a handicapped game 130 | // so only a warning is printed, and the play is allowed 131 | this.board.toPlay = (this.board.toPlay === 'b') ? 'w' : 'b'; 132 | console.error('SGF error: wrong player'); 133 | } 134 | var row = utils.l2n(action.value[1]); 135 | var col = utils.l2n(action.value[0]); 136 | // illegal play 137 | if (!this.board.play(row, col)) { 138 | return false; 139 | } 140 | } 141 | break; 142 | default: 143 | console.log('unknown sgf tag: ' + action.prop); 144 | } 145 | // action execution successful 146 | return true; 147 | }; 148 | 149 | // helper function that undoes an action 150 | Driver.prototype.undoAction = function(action) { 151 | switch (action.prop) { 152 | // remove a stone 153 | case 'AB': 154 | case 'AW': 155 | // remove multiple stones 156 | if (action.value.indexOf(':') !== -1) { 157 | var pos = action.value.split(':'); 158 | var row1 = utils.l2n(pos[0][1]), col1 = utils.l2n(pos[0][0]); 159 | var row2 = utils.l2n(pos[1][1]), col2 = utils.l2n(pos[1][0]); 160 | for (var i = row1; i <= row2; i++) { 161 | for (var j = col1; j <= col2; j++) { 162 | this.board.remove(i, j); 163 | } 164 | } 165 | // remove single stone 166 | } else { 167 | var row = utils.l2n(action.value[1]); 168 | var col = utils.l2n(action.value[0]); 169 | this.board.remove(row, col); 170 | } 171 | break; 172 | // undo a move 173 | case 'B': 174 | case 'W': 175 | if (this.board.toPlay.toUpperCase() !== action.prop) { 176 | this.board.undo(); 177 | } else { 178 | console.error('SGF error: wrong player'); 179 | } 180 | break; 181 | case 'TR': 182 | case 'CR': 183 | case 'SQ': 184 | case 'MA': 185 | var row = utils.l2n(action.value[1]); 186 | var col = utils.l2n(action.value[0]); 187 | this.markerLayer[row][col] = ''; 188 | break; 189 | default: 190 | console.log('unknown sgf tag: ' + action.prop); 191 | } 192 | }; 193 | 194 | // advance to the next node in game tree 195 | Driver.prototype.next = function(childIndex) { 196 | if (this.gameTree.next(childIndex)) { 197 | var node = this.gameTree.currentNode; 198 | // execute actions 199 | for (var i = 0; i < node.actions.length; i++) { 200 | // if action execution failed 201 | if (!this.execAction(node.actions[i])) { 202 | // undo all previous actions (in reverse order) 203 | for (var j = i-1; j >= 0; j--) { 204 | this.undoAction(node.actions[j]); 205 | } 206 | console.error('Action invalid: ', node.actions[i]); 207 | this.gameTree.prev(); 208 | return false; 209 | } 210 | } 211 | this.updateMarkerLayer(); 212 | this.updateIndicatorLayer(); 213 | return true; 214 | } 215 | return false; 216 | }; 217 | 218 | // move to the previous node in game tree 219 | Driver.prototype.prev = function() { 220 | var node = this.gameTree.currentNode; 221 | if (this.gameTree.prev()) { 222 | // undo actions 223 | node.actions.forEach(function(action) { 224 | this.undoAction(action); 225 | }, this); 226 | this.updateMarkerLayer(); 227 | this.updateIndicatorLayer(); 228 | return true; 229 | } 230 | return false; 231 | }; 232 | 233 | // play a move on board 234 | // add corresponding node to game tree 235 | Driver.prototype.play = function(row, col) { 236 | var player = this.board.toPlay.toUpperCase(); 237 | if (!this.board.play(row, col)) { 238 | return false 239 | } 240 | this.gameTree.play(player, row, col); 241 | this.updateMarkerLayer(); 242 | this.updateIndicatorLayer(); 243 | return true; 244 | }; 245 | 246 | // pass 247 | Driver.prototype.pass = function() { 248 | var player = this.board.toPlay.toUpperCase(); 249 | if (this.gameTree.pass(player)) { 250 | this.board.pass(); 251 | this.updateMarkerLayer(); 252 | this.updateIndicatorLayer(); 253 | return true; 254 | } 255 | return false; 256 | }; 257 | 258 | // delete the current node 259 | Driver.prototype.delete = function() { 260 | var node = this.gameTree.currentNode; 261 | if (this.gameTree.delete()) { 262 | node.actions.forEach(function(action) { 263 | this.undoAction(action); 264 | }, this); 265 | this.updateMarkerLayer(); 266 | this.updateIndicatorLayer(); 267 | return true; 268 | } 269 | return false; 270 | }; 271 | 272 | Driver.prototype.addStone = function(row, col, stone) { 273 | // first check that stone can be added to board 274 | if (this.board.add(row, col, stone)) { 275 | // then modify game tree 276 | if (this.gameTree.addStone(row, col, stone)) { 277 | return true; 278 | } 279 | // game tree modification failed 280 | this.board.remove(row, col); 281 | return false; 282 | } 283 | return false; 284 | }; 285 | 286 | Driver.prototype.addMarker = function(row, col, marker) { 287 | // first check that marker layer is empty at (row, col) 288 | if (this.markerLayer[row][col] === '') { 289 | // then modify game tree 290 | if (this.gameTree.addMarker(row, col, marker)) { 291 | this.markerLayer[row][col] = marker; 292 | return true; 293 | } 294 | // game tree modification failed 295 | return false; 296 | } 297 | return false; 298 | }; 299 | 300 | // navigate to a node specified by its ID 301 | Driver.prototype.navigateTo = function(nodeID) { 302 | // first go to root 303 | while (this.gameTree.currentNode !== this.gameTree.root) { 304 | this.prev(); 305 | } 306 | // dfs to find node 307 | var node = null; 308 | var dfs = function(root) { 309 | if (root.id === nodeID) { 310 | node = root; 311 | } else { 312 | root.children.forEach(function(child) { 313 | dfs(child); 314 | }); 315 | } 316 | }; 317 | dfs(this.gameTree.currentNode); 318 | // node does not exist 319 | if (!node) { 320 | // navigate to first node 321 | while (!this.gameTree.atFirstNode()) { 322 | this.next(); 323 | } 324 | return false; 325 | } 326 | // node exists, backtrack to find the path 327 | var path = []; 328 | while (node !== this.gameTree.root) { 329 | path.push(node); 330 | node = node.parent; 331 | } 332 | // navigate to node 333 | for (var i = path.length-1; i >= 0; i--) { 334 | // get childIndex 335 | var children = this.gameTree.currentNode.children; 336 | for (var j = 0; j < children.length; j++) { 337 | if (children[j] === path[i]) { 338 | break; 339 | } 340 | } 341 | // go to that child 342 | this.next(j); 343 | } 344 | return true; 345 | }; 346 | 347 | // create a deep clone of itself 348 | Driver.prototype.clone = function() { 349 | var currentNodeID = this.gameTree.currentNode.id; 350 | var sgfStr = SGF.print(this.gameTree.root); 351 | // if currentNode has no valid ID 352 | if (currentNodeID <= -1) { 353 | return new Driver(sgfStr); 354 | } 355 | // advance to node with currentNodeID 356 | var driver = new Driver(sgfStr); 357 | driver.navigateTo(currentNodeID); 358 | return driver; 359 | }; 360 | -------------------------------------------------------------------------------- /app/views.py: -------------------------------------------------------------------------------- 1 | from flask import render_template, redirect, url_for, flash, request, abort, jsonify, current_app, send_file 2 | from flask_login import login_user, logout_user, login_required, current_user 3 | from sqlalchemy.sql import func 4 | import datetime, os, base64, urllib.request 5 | from PIL import Image 6 | 7 | from . import app, db 8 | from .models import User, Kifu, Comment, KifuStar, Notification, Rank 9 | from .forms import SignUpForm, LoginForm 10 | from .sgf import validate_sgf, validate_sub_sgf, get_sgf_info, standardize_sgf 11 | 12 | 13 | # helper functions to save and retrieve kifu thumbnails 14 | def save_thumbnail(kifu, base64_str): 15 | # first write image to temperory file 16 | temp_img = current_app.config['SGF_FOLDER'] + '/.temp' + str(kifu.id) 17 | with open(temp_img, 'wb') as f: 18 | f.write(base64.decodebytes(bytes(base64_str, 'utf-8'))) 19 | # write scaled image 20 | img = Image.open(temp_img) 21 | img.thumbnail(current_app.config['THUMBNAIL_SIZE']) 22 | img.save(kifu.imagepath, 'JPEG') 23 | # remove temporary file 24 | os.remove(temp_img) 25 | 26 | def thumbnail_dataurl(kifu): 27 | with open(kifu.imagepath, 'r') as f: 28 | # append dataurl prefix 29 | return 'data:image/jpeg;base64,' + f.read() 30 | 31 | # home page 32 | @app.route('/', methods=['GET', 'POST']) 33 | def index(): 34 | login_form = LoginForm() 35 | sign_up_form = SignUpForm() 36 | 37 | if login_form.login_submit.data and login_form.validate_on_submit(): 38 | user = User.query.filter_by(email=login_form.login_email.data).first() 39 | if user is not None and user.verify_password(login_form.login_password.data): 40 | login_user(user, True) 41 | return redirect(request.args.get('next') or url_for('index')) 42 | flash('Invalid email address or password.') 43 | 44 | if sign_up_form.sign_up_submit.data and sign_up_form.validate_on_submit(): 45 | user = User( 46 | email=sign_up_form.sign_up_email.data, 47 | username=sign_up_form.sign_up_username.data, 48 | password=sign_up_form.sign_up_password.data, 49 | rank_id=sign_up_form.sign_up_rank.data, 50 | signed_up_on=datetime.datetime.now() 51 | ) 52 | db.session.add(user) 53 | db.session.commit() 54 | flash('You can now log in.') 55 | return redirect(url_for('index')) 56 | 57 | 58 | return render_template('index.html', login_form=login_form, sign_up_form=sign_up_form) 59 | 60 | # log out 61 | @app.route('/logout', methods=['GET', 'POST']) 62 | @login_required 63 | def logout(): 64 | logout_user() 65 | flash('You have been logged out.') 66 | return redirect(url_for('index')) 67 | 68 | # post comments 69 | @app.route('/comment//', methods=['POST']) 70 | @login_required 71 | def post_comment(kifu_id, node_id): 72 | # check if kifu exists 73 | kifu = Kifu.query.filter_by(id=kifu_id).first_or_404() 74 | 75 | # add comment to database 76 | comment = Comment( 77 | content=request.get_json(), 78 | timestamp=datetime.datetime.now(), 79 | author=current_user.id, 80 | kifu_id=kifu_id, 81 | node_id=node_id 82 | ) 83 | db.session.add(comment) 84 | db.session.commit() 85 | 86 | # add notifications to database 87 | # first, if the comment is not made by kifu_owner, then 88 | # kifu_owner gets a category-1 notification 89 | if current_user.id != kifu.owner_id: 90 | db.session.add(Notification( 91 | category=1, 92 | receiver_id=kifu.owner_id, 93 | comment_id=comment.id 94 | )) 95 | # second, other users who have commented on the same node 96 | # gets a category-2 notification (excluding the user who 97 | # submitted this comment and kifu_owner) 98 | other_comments = Comment.query.filter_by( 99 | kifu_id=kifu_id, 100 | node_id=node_id 101 | ).all() 102 | # generate a filtered list of users who should receive a notification 103 | filtered_authors = [] 104 | for oc in other_comments: 105 | duplicate = False 106 | for fa in filtered_authors: 107 | if oc.author == fa: 108 | duplicate = True 109 | # filter out duplicate users who commented 110 | if not duplicate: 111 | # filter out kifu_owner and notification-triggerer 112 | if oc.author != kifu.owner_id and oc.author != current_user.id: 113 | filtered_authors.append(oc.author) 114 | for fa in filtered_authors: 115 | db.session.add(Notification( 116 | category=2, 117 | receiver_id=fa, 118 | comment_id=comment.id 119 | )) 120 | db.session.commit() 121 | 122 | return jsonify(comment.serialize) 123 | 124 | # kifu main page 125 | @app.route('/kifu/', methods=['GET']) 126 | def kifu_get(kifu_id): 127 | # get query strings 128 | query_node_id = request.args.get('node_id') 129 | query_edit = request.args.get('edit') 130 | # comment_id should be of a comment on the node with id node_id 131 | query_comment_id = request.args.get('comment_id') 132 | 133 | # get kifu 134 | kifu = Kifu.query.filter_by(id=kifu_id).first_or_404() 135 | 136 | # get and process comments for kifu 137 | comments = Comment.query.filter_by(kifu_id=kifu_id).all() 138 | comments_dict = {} 139 | 140 | # arrange comments by node_id 141 | for c in comments: 142 | if c.node_id not in comments_dict: 143 | comments_dict[c.node_id] = [c] 144 | else: 145 | comments_dict[c.node_id].append(c) 146 | 147 | # sort comments by timestamp for each node_id 148 | for node_id in comments_dict: 149 | comments_dict[node_id].sort(key=lambda c: c.timestamp) 150 | # serialize comments 151 | comments_dict[node_id] = [c.serialize for c in comments_dict[node_id]] 152 | 153 | # authentication status 154 | # 0: not logged in 155 | # 1: logged in but not owner of kifu 156 | # 2: logged in and is owner of kifu 157 | if not current_user.is_authenticated: 158 | auth_status = 0 159 | elif current_user.id != kifu.owner_id: 160 | auth_status = 1 161 | else: 162 | auth_status = 2 163 | 164 | # check if kifu starred by user 165 | if not current_user.is_authenticated: 166 | starred = False 167 | else: 168 | kifustar = KifuStar.query.filter_by( 169 | user_id=current_user.id, 170 | kifu_id=kifu_id 171 | ).first() 172 | starred = False if kifustar is None else True 173 | 174 | return render_template( 175 | 'kifu/kifu.html', 176 | kifu=kifu.serialize, 177 | kifu_comments=comments_dict, 178 | auth_status=auth_status, 179 | starred=starred, 180 | node_id=query_node_id, 181 | edit=query_edit, 182 | comment_id=query_comment_id, 183 | url=url_for('kifu_get', kifu_id=kifu_id, _external=True) 184 | ) 185 | 186 | # update kifu 187 | @app.route('/kifu/', methods=['UPDATE']) 188 | @login_required 189 | def kifu_update(kifu_id): 190 | kifu = Kifu.query.filter_by(id=kifu_id).first_or_404() 191 | 192 | data = request.get_json() 193 | kifu.modified_on = datetime.datetime.now() 194 | 195 | # update SGF 196 | if 'sgf' in data: # deletedNodes and img should also be present 197 | # first ensure that new SGF contains all nodes present in the old SGF 198 | if not validate_sub_sgf(data['sgf'], kifu.sgf): 199 | abort(401) 200 | # update SGF 201 | kifu.update_sgf(data['sgf']) 202 | 203 | ### disable comment deletion because as of now, nobody is allowed 204 | ### to delete existing nodes in a kifu 205 | ### deleting comments based on data['deletedNodes'] alone without 206 | ### server-side validation is also unsafe 207 | 208 | # # delete comments on deleted nodes 209 | # for node_id in data['deletedNodes']: 210 | # comments = Comment.query.filter_by(node_id=node_id).all() 211 | # for c in comments: 212 | # db.session.delete(c) 213 | 214 | # update kifu thumbnail 215 | save_thumbnail(kifu, data['img']) 216 | 217 | # update other data (must be kifu owner) 218 | if current_user.id == kifu.owner_id: 219 | if 'blackPlayer' in data: 220 | kifu.black_player = data['blackPlayer'] 221 | if 'whitePlayer' in data: 222 | kifu.white_player = data['whitePlayer'] 223 | if 'blackRank' in data: 224 | kifu.black_rank = data['blackRank'] 225 | if 'whiteRank' in data: 226 | kifu.white_rank = data['whiteRank'] 227 | if 'komi' in data: 228 | kifu.komi = data['komi'] 229 | if 'result' in data: 230 | kifu.result = data['result'] 231 | if 'description' in data: 232 | kifu.description = data['description'] 233 | if 'title' in data: 234 | kifu.title = data['title'] 235 | 236 | db.session.add(kifu) 237 | db.session.commit() 238 | return jsonify(kifu.serialize) 239 | 240 | # delete kifu 241 | @app.route('/kifu/', methods=['DELETE']) 242 | @login_required 243 | def kifu_delete(kifu_id): 244 | kifu = Kifu.query.filter_by(id=kifu_id).first_or_404() 245 | if (kifu.owner_id != current_user.id): 246 | abort(401) 247 | 248 | # delete all notifications triggered by comments on this kifu 249 | kifu_comments = Comment.query.filter_by(kifu_id=kifu_id).all() 250 | for kc in kifu_comments: 251 | kifu_notifications = Notification.query.filter_by(comment_id=kc.id).all() 252 | for kn in kifu_notifications: 253 | db.session.delete(kn) 254 | db.session.commit() 255 | 256 | # delete all comments posted on this kifu 257 | for kc in kifu_comments: 258 | db.session.delete(kc) 259 | db.session.commit() 260 | 261 | # delete all kifustars entries of this kifu 262 | stars = KifuStar.query.filter_by(kifu_id=kifu_id) 263 | for s in stars: 264 | db.session.delete(s) 265 | db.session.commit() 266 | 267 | # delete kifu and commit 268 | db.session.delete(kifu) 269 | db.session.commit() 270 | 271 | # remove local file 272 | os.remove(kifu.filepath) 273 | os.remove(kifu.imagepath) 274 | 275 | return jsonify({ 276 | 'redirect': url_for('index', _external=True) 277 | }) 278 | 279 | # upload page 280 | @app.route('/upload', methods=['GET', 'POST']) 281 | def upload(): 282 | if request.method == 'GET': 283 | if not current_user.is_authenticated: 284 | flash('You must log in first to upload kifus') 285 | return redirect(url_for('index')) 286 | return render_template('upload.html') 287 | 288 | # request.method == 'POST' 289 | # validate SGF and get SGF info 290 | kifu_json = request.get_json() 291 | print(kifu_json) 292 | if not validate_sgf(kifu_json['sgf']): 293 | abort(400) 294 | info = get_sgf_info(kifu_json['sgf']) 295 | sgf_str = standardize_sgf(kifu_json['sgf']) 296 | 297 | # insert kifu into database 298 | kifu = Kifu( 299 | title=kifu_json['title'], 300 | description=kifu_json['description'], 301 | black_player=info['PB'], 302 | white_player=info['PW'], 303 | black_rank=info['BR'], 304 | white_rank=info['WR'], 305 | komi=info['KM'], 306 | result=info['RE'], 307 | uploaded_on=datetime.datetime.now(), 308 | owner_id=current_user.id 309 | ) 310 | db.session.add(kifu) 311 | db.session.commit() 312 | 313 | # write SGF to file 314 | with open(kifu.filepath, 'w') as f: 315 | f.write(sgf_str) 316 | 317 | # save kifu thumbnail 318 | save_thumbnail(kifu, kifu_json['img']) 319 | 320 | return jsonify({ 321 | 'redirect': url_for('kifu_get', kifu_id=kifu.id, _external=True) 322 | }) 323 | 324 | @app.route('/get-external-sgf', methods=['POST']) 325 | def get_external_sgf(): 326 | url = request.get_json()['url'] 327 | # catch network/invalid url errors 328 | try: 329 | response = urllib.request.urlopen(url, timeout=current_app.config['URL_TIMEOUT']) 330 | except Exception as e: 331 | abort(404) 332 | # catch invalid text file errors 333 | try: 334 | return jsonify({'sgf': response.read().decode('utf-8')}) 335 | except Exception as e: 336 | abort(400) 337 | 338 | # create a new, empty kifu 339 | @app.route('/new', methods=['GET']) 340 | def new(): 341 | # must be logged in 342 | if not current_user.is_authenticated: 343 | flash('You must log in to create a new kifu') 344 | return redirect(url_for('index')) 345 | 346 | # insert kifu into database 347 | kifu = Kifu( 348 | title='', 349 | uploaded_on=datetime.datetime.now(), 350 | owner_id=current_user.id 351 | ) 352 | db.session.add(kifu) 353 | db.session.commit() 354 | 355 | # write empty SGF to file 356 | with open(kifu.filepath, 'w') as f: 357 | f.write(standardize_sgf('()')) 358 | 359 | # save kifu thumbnail 360 | with open(current_app.config['EMPTY_BOARD_DATAURL'], 'r') as f: 361 | save_thumbnail(kifu, f.read()) 362 | 363 | return redirect(url_for('kifu_get', kifu_id=kifu.id, edit=True)) 364 | 365 | # star a kifu 366 | @app.route('/star/kifu/', methods=['POST']) 367 | @login_required 368 | def star_kifu(kifu_id): 369 | # check if kifu exists 370 | kifu = Kifu.query.filter_by(id=kifu_id).first_or_404() 371 | # check if user has already starred this kifu 372 | starred = KifuStar.query.filter_by( 373 | user_id=current_user.id, 374 | kifu_id=kifu_id 375 | ).first() 376 | if starred is not None: 377 | abort(400) 378 | 379 | # star kifu 380 | kifustar = KifuStar( 381 | user_id=current_user.id, 382 | kifu_id=kifu_id 383 | ) 384 | db.session.add(kifustar) 385 | db.session.commit() 386 | 387 | return jsonify({'success': True}) 388 | 389 | # unstar a kifu 390 | @app.route('/unstar/kifu/', methods=['POST']) 391 | @login_required 392 | def unstar_kifu(kifu_id): 393 | # check if kifu exists 394 | kifu = Kifu.query.filter_by(id=kifu_id).first_or_404() 395 | # check if user has already starred this kifu 396 | starred = KifuStar.query.filter_by( 397 | user_id=current_user.id, 398 | kifu_id=kifu_id 399 | ).first() 400 | if starred is None: 401 | abort(400) 402 | 403 | # unstar kifu 404 | db.session.delete(starred) 405 | db.session.commit() 406 | 407 | return jsonify({'success': True}) 408 | 409 | # download a kifu 410 | @app.route('/download/', methods=['GET']) 411 | def download(kifu_id): 412 | kifu = Kifu.query.filter_by(id=kifu_id).first_or_404() 413 | return send_file( 414 | kifu.filepath, 415 | mimetype='text/sgf', 416 | attachment_filename=str(kifu.id)+'.sgf', 417 | as_attachment=True 418 | ) 419 | 420 | # helper function that generates a kifu pagination 421 | # based on constraints of its arguments 422 | def get_kifu_pagination(page, sort_by, time_frame, display_in, uploaded_by=None, saved_by=None): 423 | # kifus uploaded after earliest_time will be included 424 | current_time = datetime.datetime.now() 425 | if time_frame == 'day': 426 | earliest_time = current_time - datetime.timedelta(days=1) 427 | elif time_frame == 'week': 428 | earliest_time = current_time - datetime.timedelta(days=7) 429 | elif time_frame == 'month': 430 | earliest_time = current_time - datetime.timedelta(days=30) 431 | elif time_frame == 'year': 432 | earliest_time = current_time - datetime.timedelta(days=365) 433 | else: 434 | earliest_time = None 435 | 436 | # subquery to count number of comments each kifu has 437 | count_query = db.session.query(Comment.kifu_id, func.count('*').label('comment_count')).group_by(Comment.kifu_id).subquery() 438 | 439 | # query to get all the kifu info 440 | kifu_query = db.session.query(Kifu, User, count_query.c.comment_count).join(User).outerjoin(count_query, Kifu.id==count_query.c.kifu_id) 441 | 442 | # filter query if uploaded_by or save_by is specified 443 | if uploaded_by is not None: 444 | kifu_query = kifu_query.filter(Kifu.owner_id == uploaded_by) 445 | elif saved_by is not None: 446 | saved_kifu_ids = [sk_id[0] for sk_id in db.session.query(KifuStar.kifu_id).filter(KifuStar.user_id==saved_by).distinct()] 447 | # print(saved_kifu_ids) 448 | kifu_query = kifu_query.filter(Kifu.id.in_(saved_kifu_ids)) 449 | 450 | # filter query by upload date 451 | if earliest_time is not None: 452 | kifu_query = kifu_query.filter(Kifu.uploaded_on >= earliest_time) 453 | 454 | # order query based on query strings 455 | if sort_by == 'date': 456 | if display_in == 'desc': 457 | sorted_query = kifu_query.order_by(Kifu.uploaded_on.desc()) 458 | else: 459 | sorted_query = kifu_query.order_by(Kifu.uploaded_on.asc()) 460 | else: 461 | if display_in == 'desc': 462 | sorted_query = kifu_query.order_by(count_query.c.comment_count.desc()) 463 | else: 464 | sorted_query = kifu_query.order_by(count_query.c.comment_count.asc()) 465 | 466 | # paginate 467 | sorted_pagination = sorted_query.paginate( 468 | page=page, 469 | per_page=current_app.config['KIFU_PERPAGE'], 470 | error_out=True 471 | ) 472 | return sorted_pagination 473 | 474 | # browse kifu 475 | @app.route('/browse', methods=['GET']) 476 | @app.route('/browse/user-upload/', methods=['GET']) 477 | @app.route('/browse/user-save/', methods=['GET']) 478 | def browse_kifu(upload_user_id=None, save_user_id=None): 479 | # get query strings 480 | page = int(request.args.get('page')) if request.args.get('page') else 1 481 | sort_by = request.args.get('sort-by') if request.args.get('sort-by') else 'date' 482 | time_frame = request.args.get('time-frame') if request.args.get('time-frame') else 'all-time' 483 | display_in = request.args.get('display-in') if request.args.get('display-in') else 'desc' 484 | 485 | if upload_user_id is not None: 486 | user = User.query.filter_by(id=upload_user_id).first_or_404() 487 | kifu_pagination = get_kifu_pagination(page, sort_by, time_frame, display_in, uploaded_by=upload_user_id) 488 | base_url = '/browse/user-upload/' + str(upload_user_id) 489 | browse_title = 'Uploads by %s (%s)' % (user.username, user.rank) 490 | elif save_user_id is not None: 491 | user = User.query.filter_by(id=save_user_id).first_or_404() 492 | kifu_pagination = get_kifu_pagination(page, sort_by, time_frame, display_in, saved_by=save_user_id) 493 | base_url = '/browse/user-save/' + str(save_user_id) 494 | browse_title = 'Kifus saved by %s (%s)' % (user.username, user.rank) 495 | else: 496 | kifu_pagination = get_kifu_pagination(page, sort_by, time_frame, display_in) 497 | base_url = '/browse' 498 | browse_title = 'All uploads on Kifutalk' 499 | 500 | return render_template( 501 | 'browse.html', 502 | base_url=base_url, 503 | browse_title=browse_title, 504 | items=kifu_pagination.items, 505 | page_num=kifu_pagination.page, 506 | has_next=kifu_pagination.has_next, 507 | has_prev=kifu_pagination.has_prev, 508 | query_string_list=[sort_by, time_frame, display_in] 509 | ) 510 | 511 | @app.route('/comments/user/', methods=['GET']) 512 | def browse_comment(user_id): 513 | page = int(request.args.get('page')) if request.args.get('page') else 1 514 | user = User.query.filter_by(id=user_id).first_or_404(); 515 | comment_query = db.session.query(Comment, Kifu.title, User.username, Rank.rank_en).join(Kifu, Comment.kifu_id==Kifu.id).join(User, Comment.author==User.id).join(Rank, User.rank_id==Rank.id).filter(Comment.author==user_id).order_by(Comment.timestamp.desc()) 516 | comment_pagination = comment_query.paginate( 517 | page=page, 518 | per_page=current_app.config['COMMENT_PERPAGE'], 519 | error_out=True 520 | ) 521 | return render_template( 522 | 'browse-comments.html', 523 | uid=user_id, 524 | items=comment_pagination.items, 525 | page_num=comment_pagination.page, 526 | has_next=comment_pagination.has_next, 527 | has_prev=comment_pagination.has_prev 528 | ) 529 | 530 | # mark a notification as read 531 | @app.route('/read-notification/', methods=['POST']) 532 | def read_notification(notification_id): 533 | notification = Notification.query.filter_by(id=notification_id).first_or_404() 534 | notification.read = True 535 | db.session.add(notification) 536 | db.session.commit() 537 | return jsonify({'success': True}) 538 | -------------------------------------------------------------------------------- /app/static/js/controller.js: -------------------------------------------------------------------------------- 1 | var Controller = function(kifu, kifuComments, boardCanvas) { 2 | // retrieve data from server 3 | this.kifu = kifu; 4 | this.kifuComments = kifuComments; 5 | this.authStatus = authStatus; 6 | this.starred = starred; 7 | 8 | // HTML elements that controller needs 9 | this.html = { 10 | // comments 11 | 'commentList': document.getElementById('comment-list'), 12 | 'commentForm': document.getElementById('comment-form'), 13 | 'commentInput': document.getElementById('comment-input'), 14 | 'commentSubmit': document.getElementById('comment-submit'), 15 | // info 16 | 'title': document.getElementById('kifu-title'), 17 | 'blackPlayer': document.querySelector('#info .black-player'), 18 | 'blackRank': document.querySelector('#info .black-rank'), 19 | 'whitePlayer': document.querySelector('#info .white-player'), 20 | 'whiteRank': document.querySelector('#info .white-rank'), 21 | 'komi': document.querySelector('#info .komi span'), 22 | 'result': document.querySelector('#info .result span'), 23 | 'description': document.querySelector('#info .description p'), 24 | // navigation 25 | 'play': document.getElementById('play'), 26 | 'pause': document.getElementById('pause'), 27 | 'beginning': document.getElementById('beginning'), 28 | 'prev': document.getElementById('prev'), 29 | 'next': document.getElementById('next'), 30 | 'end': document.getElementById('end'), 31 | 'toggleEdit': document.getElementById('toggle-edit'), 32 | 'save': document.getElementById('save'), 33 | 'cancel': document.getElementById('cancel'), 34 | // edit 35 | 'edit': document.getElementById('edit'), 36 | 'deleteNode': document.getElementById('delete-node'), 37 | 'pass': document.getElementById('pass'), 38 | 'editMode': document.getElementsByClassName('edit-mode'), 39 | 'addBlack': document.getElementById('add-black'), 40 | 'addWhite': document.getElementById('add-white'), 41 | 'addStone': document.getElementById('add-stone'), 42 | 'addStoneMenu': document.getElementById('add-stone-menu'), 43 | 'triangle': document.getElementById('triangle'), 44 | 'square': document.getElementById('square'), 45 | // action bar 46 | 'starKifu': document.getElementById('star'), 47 | 'unstarKifu': document.getElementById('unstar'), 48 | 'deleteKifu': document.getElementById('delete-kifu'), 49 | 'deleteYes': document.getElementById('delete-yes'), 50 | 'deleteNo': document.getElementById('delete-no'), 51 | 'downloadKifu': document.getElementById('download'), 52 | 'shareKifu': document.getElementById('share'), 53 | 'shareLabel': document.querySelector('.share-dropdown label'), 54 | 'shareInput': document.querySelector('.share-dropdown input') 55 | }; 56 | 57 | // cursor modes mapped to buttons 58 | this.cursorButtonMap = {}; 59 | this.cursorButtonMap[constants.cursor.ADD_BLACK] = this.html.addBlack; 60 | this.cursorButtonMap[constants.cursor.ADD_WHITE] = this.html.addWhite; 61 | this.cursorButtonMap[constants.cursor.MARK_TRIANGLE] = this.html.triangle; 62 | this.cursorButtonMap[constants.cursor.MARK_SQUARE] = this.html.square; 63 | 64 | // initialize game 65 | this.boardCanvas = boardCanvas; 66 | this.initStarAuth(); 67 | 68 | // application state variable 69 | this.autoPlayIntervalID = null; // to control auto play 70 | this.isAutoPlaying = false; 71 | this.isEditing = false; 72 | this.isSelectingAdd = false; // if the user is selecting an AB/AW variation 73 | this.nodesDeletedDuringEdit = []; // keep track of which nodes are deleted during editting 74 | this.driverBackup = null; 75 | this.textBackup = ''; // info updating failsafe 76 | this.isFallbackContent = false; // also used to info updating 77 | this.cursorMode = constants.cursor.PLAY_AND_SELECT; 78 | 79 | // update navigation, edit, and comment interface 80 | this.updateNavEdit(); 81 | this.updateCommentList(); 82 | 83 | // attach event listeners 84 | this.addActionEventListeners(); 85 | this.addCanvasEventListeners(); 86 | this.addKeyboardEventListeners(); 87 | this.addNavigationEventListeners(); 88 | this.addCommentEventListeners(); 89 | this.addEditEventListeners(); 90 | this.addInfoEventListeners(); 91 | }; 92 | 93 | Controller.prototype.createAddVarElement = function(childIndex) { 94 | var self = this; 95 | var addVarLi = document.createElement('li'); 96 | addVarLi.textContent = 'Variation ' + (childIndex+1); 97 | addVarLi.addEventListener('click', function(e) { 98 | self.next(childIndex); 99 | }); 100 | 101 | return addVarLi; 102 | } 103 | 104 | Controller.prototype.updateNavEdit = function() { 105 | var gameTree = this.boardCanvas.driver.gameTree; 106 | 107 | // check for beginning/end 108 | if (gameTree.atFirstNode()) { 109 | this.html.beginning.disabled = true; 110 | this.html.prev.disabled = true; 111 | } else { 112 | this.html.beginning.disabled = false; 113 | this.html.prev.disabled = false; 114 | } 115 | if (gameTree.atEnd()) { 116 | this.html.end.disabled = true; 117 | this.html.next.disabled = true; 118 | this.html.play.disabled = true; 119 | } else { 120 | this.html.end.disabled = false; 121 | this.html.next.disabled = false; 122 | this.html.play.disabled = false; 123 | } 124 | 125 | // check if game is autoplaying 126 | if (this.isAutoPlaying) { 127 | // switch buttons 128 | this.html.play.style.display = 'none'; 129 | this.html.pause.style.display = 'inline-block'; 130 | // disable beginning, prev, next, end, and toggleEdit 131 | this.html.beginning.disabled = true; 132 | this.html.prev.disabled = true; 133 | this.html.next.disabled = true; 134 | this.html.end.disabled = true; 135 | this.html.toggleEdit.disabled = true; 136 | } else { 137 | // switch buttons 138 | this.html.pause.style.display = 'none'; 139 | this.html.play.style.display = 'inline-block'; 140 | // enable beginning, prev if not at first node 141 | if (!gameTree.atFirstNode()) { 142 | this.html.beginning.disabled = false; 143 | this.html.prev.disabled = false; 144 | } 145 | // enable next, end if not at end 146 | if (!gameTree.atEnd()) { 147 | this.html.next.disabled = false; 148 | this.html.end.disabled = false; 149 | } 150 | // enable toggleEdit if user is logged in 151 | if (this.authStatus > 0) { 152 | this.html.toggleEdit.disabled = false; 153 | } 154 | } 155 | 156 | // check if in edit mode 157 | if (this.isEditing) { 158 | // change buttons 159 | this.html.toggleEdit.style.display = 'none'; 160 | this.html.save.style.display = 'inline-block'; 161 | this.html.cancel.style.display = 'inline-block'; 162 | // enable buttons 163 | for (var i = 0; i < this.html.editMode.length; i++) { 164 | this.html.editMode[i].disabled = false; 165 | } 166 | // disable deleteNode if the game is at a node that already 167 | // existed before the edit session, even if user is kifu owner 168 | if (gameTree.currentNode.id < this.driverBackup.gameTree.nextNodeID) { 169 | this.html.deleteNode.disabled = true; 170 | } 171 | // disable play button 172 | this.html.play.disabled = true; 173 | // disable comments 174 | this.html.commentInput.disabled = true; 175 | this.html.commentSubmit.disabled = true; 176 | } else { 177 | // change buttons 178 | this.html.save.style.display = 'none'; 179 | this.html.cancel.style.display = 'none'; 180 | this.html.toggleEdit.style.display = 'inline-block'; 181 | // disable buttons 182 | for (var i = 0; i < this.html.editMode.length; i++) { 183 | this.html.editMode[i].disabled = true; 184 | } 185 | // enable play button if not at end 186 | if (!gameTree.atEnd()) { 187 | this.html.play.disabled = false; 188 | } 189 | // enable comments (only when logged in) 190 | if (this.authStatus !== 0) { 191 | this.html.commentInput.disabled = false; 192 | this.html.commentSubmit.disabled = false; 193 | } 194 | } 195 | 196 | // check if next variation involves adding stone 197 | // addStone would only display when not in edit mode 198 | if (gameTree.nextVar.add.length !== 0 && !this.isEditing) { 199 | // remove addBlack and addWhite 200 | this.html.addBlack.style.display = 'none'; 201 | this.html.addWhite.style.display = 'none'; 202 | // add addStone 203 | this.html.addStone.style.display = 'inline-block'; 204 | this.html.addStone.disabled = false; 205 | } else { 206 | // add addBlack and addWhite 207 | this.html.addBlack.style.display = 'inline-block'; 208 | this.html.addWhite.style.display = 'inline-block'; 209 | // remove addStone 210 | this.html.addStone.style.display = 'none'; 211 | } 212 | 213 | // check if next variation involves pass 214 | // enable pass only when editting or when pass move is availabe and not autoplaying 215 | if (this.isEditing || gameTree.nextVar.pass !== -1) { 216 | // enable pass 217 | this.html.pass.disabled = false; 218 | } else { 219 | // disable pass 220 | this.html.pass.disabled = true; 221 | } 222 | 223 | // check cursor mode 224 | switch (this.cursorMode) { 225 | // play and select, remove active from all cursor buttons 226 | case constants.cursor.PLAY_AND_SELECT: 227 | for (var cur in this.cursorButtonMap) { 228 | this.cursorButtonMap[cur].classList.remove('active'); 229 | } 230 | break; 231 | // all other cases 232 | default: 233 | // disable all edit buttons but the current cursor button 234 | for (var i = 0; i < this.html.editMode.length; i++) { 235 | this.html.editMode[i].disabled = true; 236 | } 237 | this.cursorButtonMap[this.cursorMode].disabled = false; 238 | // mark the enabled button as active 239 | this.cursorButtonMap[this.cursorMode].classList.add('active'); 240 | break; 241 | } 242 | 243 | // check if addStone is active 244 | // note: pressing any other button would set isSelectingAdd to false 245 | if (this.isSelectingAdd) { 246 | // clear and populate addStoneMenu 247 | this.html.addStoneMenu.innerHTML = ''; 248 | gameTree.nextVar.add.forEach(function(addVar) { 249 | this.html.addStoneMenu.appendChild( 250 | this.createAddVarElement(addVar.index) 251 | ); 252 | }, this); 253 | // display addStoneMenu 254 | this.html.addStoneMenu.style.display = 'inline-block'; 255 | // mark addStone as active 256 | this.html.addStone.classList.add('active'); 257 | } else { 258 | // hide addStoneMenu 259 | this.html.addStoneMenu.style.display = 'none'; 260 | // mark addStone as inactive 261 | this.html.addStone.classList.remove('active'); 262 | } 263 | }; 264 | 265 | Controller.prototype.createCommentElement = function(comment) { 266 | var c = document.createElement('li'); 267 | c.setAttribute('comment-id', comment.id); 268 | 269 | var author = document.createElement('span'); 270 | var timestamp = document.createElement('span'); 271 | var text = document.createElement('p'); 272 | 273 | author.textContent = comment.author_username + ' (' + comment.author_rank + ')'; 274 | timestamp.textContent = comment.timestamp; 275 | text.textContent = comment.content; 276 | 277 | c.classList.add('comment'); 278 | author.classList.add('user'); 279 | timestamp.classList.add('time'); 280 | text.classList.add('text'); 281 | 282 | c.appendChild(author); 283 | c.appendChild(timestamp); 284 | c.appendChild(text); 285 | 286 | return c; 287 | }; 288 | 289 | Controller.prototype.updateCommentList = function() { 290 | this.html.commentList.innerHTML = ''; 291 | var nodeID = this.boardCanvas.driver.gameTree.currentNode.id; 292 | var comments = this.kifuComments[nodeID]; 293 | // if comments exist 294 | if (comments) { 295 | var self = this; 296 | comments.forEach(function(comment) { 297 | self.html.commentList.appendChild(self.createCommentElement(comment)); 298 | }); 299 | // if there are no comments yet 300 | } else { 301 | var noComment = document.createElement('p'); 302 | noComment.classList.add('no-comment'); 303 | noComment.textContent = 'No comments'; 304 | this.html.commentList.appendChild(noComment); 305 | } 306 | }; 307 | 308 | Controller.prototype.initStarAuth = function() { 309 | // display star/unstar button 310 | if (this.starred) { 311 | this.html.unstarKifu.parentNode.display = 'inline-block'; 312 | this.html.starKifu.parentNode.style.display = 'none'; 313 | } else { 314 | this.html.starKifu.parentNode.display = 'inline-block'; 315 | this.html.unstarKifu.parentNode.style.display = 'none'; 316 | } 317 | 318 | switch(this.authStatus) { 319 | case 0: 320 | // not logged in, disable comments and edit button, remove delete button 321 | this.html.commentInput.disabled = true; 322 | this.html.commentSubmit.disabled = true; 323 | this.html.toggleEdit.disabled = true; 324 | // also hide star buttons 325 | this.html.starKifu.style.display = 'none'; 326 | this.html.unstarKifu.style.display = 'none'; 327 | 328 | this.html.deleteKifu.remove(); 329 | break; 330 | case 1: 331 | // not owner, remove delete button 332 | this.html.deleteKifu.remove(); 333 | break; 334 | case 2: 335 | // is owner, no action required (as of now) 336 | break; 337 | } 338 | }; 339 | 340 | Controller.prototype.next = function(childIndex) { 341 | if (this.boardCanvas.next(childIndex)) { 342 | this.isSelectingAdd = false; 343 | this.updateCommentList(); 344 | this.updateNavEdit(); 345 | return true; 346 | } 347 | return false; 348 | }; 349 | 350 | Controller.prototype.prev = function() { 351 | if (this.boardCanvas.prev()) { 352 | this.isSelectingAdd = false; 353 | this.updateCommentList(); 354 | this.updateNavEdit(); 355 | return true; 356 | } 357 | return false; 358 | }; 359 | 360 | Controller.prototype.play = function(row, col) { 361 | if (this.boardCanvas.play(row, col)) { 362 | this.isSelectingAdd = false; 363 | this.updateCommentList(); 364 | this.updateNavEdit(); 365 | return true; 366 | } 367 | return false; 368 | }; 369 | 370 | Controller.prototype.pass = function() { 371 | if (this.boardCanvas.pass()) { 372 | this.isSelectingAdd = false; 373 | this.updateCommentList(); 374 | this.updateNavEdit(); 375 | return true; 376 | } 377 | return false; 378 | }; 379 | 380 | Controller.prototype.delete = function() { 381 | var node = this.boardCanvas.driver.gameTree.currentNode; 382 | if (this.boardCanvas.delete()) { 383 | this.isSelectingAdd = false; 384 | this.updateCommentList(); 385 | this.updateNavEdit(); 386 | // delete success, add the IDs of node and its descendants 387 | node.getChildren().forEach(function(child) { 388 | this.nodesDeletedDuringEdit.push(child.id); 389 | }, this); 390 | return true; 391 | } 392 | // delete failed, do nothing 393 | return false; 394 | }; 395 | 396 | Controller.prototype.addStone = function(row, col, stone) { 397 | if (this.boardCanvas.addStone(row, col, stone)) { 398 | this.updateCommentList(); 399 | this.updateNavEdit(); 400 | return true; 401 | } 402 | return false; 403 | }; 404 | 405 | Controller.prototype.addMarker = function(row, col, marker) { 406 | if (this.boardCanvas.addMarker(row, col, marker)) { 407 | this.updateCommentList(); 408 | this.updateNavEdit(); 409 | return true; 410 | } 411 | return false; 412 | }; 413 | 414 | // add event listeners to canvas 415 | Controller.prototype.addCanvasEventListeners = function() { 416 | var bc = this.boardCanvas; 417 | var self = this; 418 | 419 | // left click plays a move or chooses a variation 420 | bc.canvas.addEventListener('click', function(e) { 421 | // get board coordinates 422 | var rect = bc.canvas.getBoundingClientRect(); 423 | var bx = utils.c2b( 424 | e.clientX - Math.floor(rect.left * globalZoom) + 1, 425 | config.canvas.sp * globalZoom / bc.scale, 426 | config.canvas.lw * globalZoom 427 | ); 428 | var by = utils.c2b( 429 | e.clientY - Math.floor(rect.top * globalZoom) + 1, 430 | config.canvas.sp * globalZoom / bc.scale, 431 | config.canvas.lw * globalZoom 432 | ); 433 | // check for invalid coordinates 434 | if (bx == -1 || by == -1) { 435 | return; 436 | } 437 | 438 | // different cursor modes 439 | switch(self.cursorMode) { 440 | case constants.cursor.PLAY_AND_SELECT: 441 | // check if clicking on an existing variation 442 | var playVars = bc.driver.gameTree.nextVar.play; 443 | var index = -1; 444 | for (var i = 0; i < playVars.length; i++) { 445 | if (playVars[i].row === by && playVars[i].col === bx) { 446 | index = playVars[i].index; 447 | break; 448 | } 449 | } 450 | // plays a new move (requires edit mode) 451 | if (index === -1) { 452 | if (self.isEditing) { 453 | self.play(by, bx); 454 | } 455 | // chooses a variation 456 | } else { 457 | self.next(index); 458 | } 459 | break; 460 | case constants.cursor.ADD_BLACK: 461 | self.addStone(by, bx, 'b'); 462 | break; 463 | case constants.cursor.ADD_WHITE: 464 | self.addStone(by, bx, 'w'); 465 | break; 466 | case constants.cursor.MARK_TRIANGLE: 467 | self.addMarker(by, bx, 't'); 468 | break; 469 | case constants.cursor.MARK_SQUARE: 470 | self.addMarker(by, bx, 's'); 471 | break; 472 | default: 473 | console.error('Unknown cursor mode: ' + self.cursorMode); 474 | } 475 | }); 476 | 477 | /* 478 | right click deletes the current node (requires edit mode) - currently disabled 479 | this.boardCanvas.canvas.addEventListener('contextmenu', function(e) { 480 | e.preventDefault(); 481 | if (self.isEditing) { 482 | self.delete(); 483 | } 484 | }); 485 | */ 486 | }; 487 | 488 | // enable keyboard navigation and control 489 | Controller.prototype.addKeyboardEventListeners = function() { 490 | var self = this; 491 | document.onkeydown = function(e) { 492 | if (document.activeElement == self.html.commentInput || document.activeElement.contentEditable == "true") { 493 | return; 494 | } 495 | switch (e.keyCode) { 496 | // spacebar starts/stops playback 497 | case 32: 498 | if (self.isAutoplaying) { 499 | self.html.pause.click(); 500 | } else { 501 | self.html.play.click(); 502 | } 503 | break; 504 | // left arrow goes to parent node 505 | case 37: 506 | self.html.prev.click(); 507 | break; 508 | // right arrow goes to next node (first child) 509 | case 39: 510 | self.html.next.click(); 511 | break; 512 | } 513 | } 514 | }; 515 | 516 | // add event listeners to action bar 517 | Controller.prototype.addActionEventListeners = function() { 518 | var self = this; 519 | 520 | // star and unstar kifu 521 | this.html.starKifu.addEventListener('click', function(e) { 522 | if (!self.starred) { 523 | self.starKifu(self.kifu.id); 524 | } 525 | }); 526 | this.html.unstarKifu.addEventListener('click', function(e) { 527 | if (self.starred) { 528 | self.unstarKifu(self.kifu.id); 529 | } 530 | }); 531 | 532 | // delete kifu 533 | this.html.deleteKifu.addEventListener('click', function(e) { 534 | if (self.html.deleteKifu.style.display === 'none') { 535 | self.html.deleteKifu.style.display = 'block'; 536 | self.html.deleteYes.parentNode.style.display = 'none'; 537 | } else { 538 | self.html.deleteKifu.style.display = 'none'; 539 | self.html.deleteYes.parentNode.style.display = 'block'; 540 | } 541 | }); 542 | 543 | this.html.deleteYes.addEventListener('click', function(e) { 544 | self.deleteKifu(self.kifu.id); 545 | }); 546 | 547 | this.html.deleteNo.addEventListener('click', function(e) { 548 | self.html.deleteKifu.style.display = 'block'; 549 | self.html.deleteYes.parentNode.style.display = 'none'; 550 | }); 551 | 552 | // download kifu 553 | this.html.downloadKifu.addEventListener('click', function(e) { 554 | window.location.replace('/download/' + self.kifu.id); 555 | }); 556 | 557 | // share kifu 558 | var setShareLink = function(shareAtThisMove) { 559 | var currentNodeID = self.boardCanvas.driver.gameTree.currentNode.id; 560 | if (shareAtThisMove) { 561 | self.html.shareInput.value = kifuURL + '?node_id=' + currentNodeID; 562 | } else { 563 | self.html.shareInput.value = kifuURL; 564 | } 565 | } 566 | 567 | this.html.shareKifu.addEventListener('click', function(e) { 568 | // reset share-at-this-move toggle 569 | self.html.shareLabel.classList.remove('active'); 570 | setShareLink(false); 571 | 572 | if (self.html.shareLabel.parentNode.style.display === 'block') { 573 | self.html.shareKifu.classList.remove('active'); 574 | self.html.shareLabel.parentNode.style.display = 'none'; 575 | } else { 576 | self.html.shareKifu.classList.add('active'); 577 | self.html.shareLabel.parentNode.style.display = 'block' 578 | } 579 | }); 580 | 581 | // toggle share-at-this-move 582 | this.html.shareLabel.addEventListener('click', function(e) { 583 | if (self.html.shareLabel.classList.contains('active')) { 584 | self.html.shareLabel.classList.remove('active'); 585 | setShareLink(false); 586 | } else { 587 | self.html.shareLabel.classList.add('active'); 588 | setShareLink(true); 589 | } 590 | }); 591 | }; 592 | 593 | // add event listeners to navigation 594 | Controller.prototype.addNavigationEventListeners = function() { 595 | var self = this; 596 | 597 | this.html.beginning.addEventListener('click', function(e) { 598 | while (!self.boardCanvas.driver.gameTree.atFirstNode()) { 599 | self.boardCanvas.driver.prev(); 600 | } 601 | self.boardCanvas.render(); 602 | self.isSelectingAdd = false; 603 | self.updateCommentList(); 604 | self.updateNavEdit(); 605 | }); 606 | 607 | this.html.prev.addEventListener('click', function(e) { 608 | self.prev(); 609 | }); 610 | 611 | this.html.play.addEventListener('click', function(e) { 612 | self.isAutoPlaying = true; 613 | // begin auto-play immediately 614 | if (!self.boardCanvas.driver.gameTree.atEnd()) { 615 | self.next(); 616 | } 617 | self.autoPlayIntervalID = setInterval(function() { 618 | if (self.boardCanvas.driver.gameTree.atEnd() || !self.next()) { 619 | clearInterval(self.autoPlayIntervalID); 620 | self.isAutoPlaying = false; 621 | self.autoPlayIntervalID = null; 622 | self.updateNavEdit(); 623 | } 624 | }, 500); 625 | }); 626 | 627 | this.html.pause.addEventListener('click', function(e) { 628 | clearInterval(self.autoPlayIntervalID); 629 | self.isAutoPlaying = false; 630 | self.autoPlayIntervalID = null; 631 | self.updateNavEdit(); 632 | }); 633 | 634 | this.html.next.addEventListener('click', function(e) { 635 | self.next(); 636 | }); 637 | 638 | this.html.end.addEventListener('click', function(e) { 639 | while (!self.boardCanvas.driver.gameTree.atEnd()) { 640 | if (!self.boardCanvas.driver.next()) { 641 | break; 642 | } 643 | } 644 | self.boardCanvas.render(); 645 | self.isSelectingAdd = false; 646 | self.updateCommentList(); 647 | self.updateNavEdit(); 648 | }); 649 | 650 | // pass is in edit section, but could also be used in navigation 651 | this.html.pass.addEventListener('click', function(e) { 652 | self.pass(); 653 | }); 654 | 655 | // addStone also in edit section, but it is used for navigation 656 | this.html.addStone.addEventListener('click', function(e) { 657 | self.isSelectingAdd = self.isSelectingAdd ? false: true; 658 | self.updateNavEdit(); 659 | }); 660 | }; 661 | 662 | // add event listeners to editting section 663 | Controller.prototype.addEditEventListeners = function() { 664 | var self = this; 665 | 666 | // toggle edit 667 | this.html.toggleEdit.addEventListener('click', function(e) { 668 | self.isEditing = true; 669 | self.nodesDeletedDuringEdit = []; // reset deleted nodes 670 | self.isSelectingAdd = false; 671 | // backup controller state 672 | self.backupBoardCanvas(); 673 | self.updateNavEdit(); 674 | }); 675 | 676 | // save all the changes 677 | this.html.save.addEventListener('click', function(e) { 678 | // restore cursorMode to PLAY_AND_SELECT 679 | if (self.cursorMode !== constants.cursor.PLAY_AND_SELECT) { 680 | self.cursorMode = constants.cursor.PLAY_AND_SELECT; 681 | } 682 | // update kifu 683 | self.updateKifu( 684 | self.kifu.id, 685 | SGF.print(self.boardCanvas.driver.gameTree.root), 686 | self.nodesDeletedDuringEdit 687 | ); 688 | }); 689 | 690 | // cancel changes 691 | this.html.cancel.addEventListener('click', function(e) { 692 | // restore cursorMode to PLAY_AND_SELECT 693 | if (self.cursorMode !== constants.cursor.PLAY_AND_SELECT) { 694 | self.cursorMode = constants.cursor.PLAY_AND_SELECT; 695 | } 696 | self.isEditing = false; 697 | self.updateNavEdit(); 698 | // restore backup 699 | self.restoreBoardCanvas(); 700 | }); 701 | 702 | // delete node 703 | this.html.deleteNode.addEventListener('click', function(e) { 704 | self.delete(); 705 | }); 706 | 707 | // switch cursor modes 708 | cursors = Object.keys(this.cursorButtonMap).map(function(cur) { 709 | return parseInt(cur); 710 | }); 711 | cursors.forEach(function(cur) { 712 | self.cursorButtonMap[cur].addEventListener('click', function(e) { 713 | self.cursorMode = self.cursorMode === cur? constants.cursor.PLAY_AND_SELECT: cur; 714 | self.updateNavEdit(); 715 | }); 716 | }); 717 | }; 718 | 719 | // add event listeners for updating game information (through contentEditable) 720 | Controller.prototype.addInfoEventListeners = function() { 721 | var self = this; 722 | var addListener = function(contentElement, dataKey) { 723 | // clicking on editable info backs the info up 724 | contentElement.addEventListener('focus', function() { 725 | self.textBackup = contentElement.textContent; 726 | self.isFallbackContent = contentElement.classList.contains('fallback'); 727 | // if contentElement is fallback, then clear the text and remove the fallback class 728 | if (self.isFallbackContent) { 729 | contentElement.textContent = ''; 730 | contentElement.classList.remove('fallback'); 731 | } 732 | }); 733 | 734 | var update = function() { 735 | var xhr = new XMLHttpRequest(); 736 | xhr.addEventListener('readystatechange', function() { 737 | // revert to backup if change failed 738 | // also re-add fallback class if content was originally fallback 739 | if (xhr.readyState === 4 && xhr.status !== 200) { 740 | contentElement.textContent = self.textBackup; 741 | if (self.isFallbackContent) { 742 | contentElement.classList.add('fallback'); 743 | } 744 | } 745 | }); 746 | // construct url and data 747 | var url = '/kifu/' + self.kifu.id; 748 | var data = {}; 749 | data[dataKey] = contentElement.textContent; 750 | data = JSON.stringify(data); 751 | // if content has been changed to empty 752 | if (contentElement.textContent === '') { 753 | contentElement.textContent = self.textBackup; 754 | if (self.isFallbackContent) { 755 | contentElement.classList.add('fallback'); 756 | } 757 | // if content has actually been changed 758 | } else if (self.textBackup !== contentElement.textContent) { 759 | xhr.open('UPDATE', url); 760 | xhr.setRequestHeader('Content-type', 'application/json'); 761 | xhr.send(data); 762 | } 763 | }; 764 | // after user finishes changing the info, send change to server 765 | contentElement.addEventListener('blur', update); 766 | contentElement.addEventListener('keydown', function(e) { 767 | if (e.keyCode === 13) { // enter key 768 | e.preventDefault(); 769 | contentElement.blur(); 770 | update(); 771 | } 772 | }); 773 | }; 774 | 775 | addListener(this.html.blackPlayer, 'blackPlayer'); 776 | addListener(this.html.whitePlayer, 'whitePlayer'); 777 | addListener(this.html.blackRank, 'blackRank'); 778 | addListener(this.html.whiteRank, 'whiteRank'); 779 | addListener(this.html.komi, 'komi'); 780 | addListener(this.html.result, 'result'); 781 | addListener(this.html.description, 'description'); 782 | addListener(this.html.title, 'title'); 783 | }; 784 | 785 | // add event listeners to comment-list and comment-form 786 | Controller.prototype.addCommentEventListeners = function() { 787 | var self = this; 788 | var submit = function() { 789 | var comment = (new FormData(self.html.commentForm)).get('comment-input'); 790 | // only post non-empty comments 791 | if (comment.trim() !== '') { 792 | self.postComment(comment); 793 | } 794 | } 795 | 796 | // submit by clicking 797 | this.html.commentForm.addEventListener('submit', function(e) { 798 | e.preventDefault(); 799 | submit(); 800 | }); 801 | 802 | // submit by hitting ENTER 803 | this.html.commentForm.addEventListener('keydown', function(e) { 804 | if (e.keyCode === 13) { 805 | e.preventDefault(); 806 | submit(); 807 | } 808 | }); 809 | }; 810 | 811 | Controller.prototype.postComment = function(comment) { 812 | var kifuID = this.kifu.id; 813 | var nodeID = this.boardCanvas.driver.gameTree.currentNode.id; 814 | var self = this; 815 | 816 | var xhr = new XMLHttpRequest(); 817 | xhr.addEventListener('readystatechange', function() { 818 | // post initiated 819 | if (xhr.readyState === 1) { 820 | // disable submit button 821 | self.html.commentSubmit.disabled = true; 822 | // post successful 823 | } else if (xhr.readyState === 4 && xhr.status === 200) { 824 | // add current comment to kifuComments 825 | var comment = JSON.parse(xhr.responseText); 826 | if (!self.kifuComments[nodeID]) { 827 | self.kifuComments[nodeID] = [comment]; 828 | } else { 829 | self.kifuComments[nodeID].push(comment); 830 | } 831 | // re-enable submit button 832 | self.html.commentSubmit.disabled = false; 833 | // clear comment field 834 | self.html.commentInput.value = ''; 835 | // update comment-list 836 | self.updateCommentList(); 837 | } 838 | }); 839 | 840 | // send post request to server 841 | var url = '/comment/' + kifuID + '/' + nodeID; 842 | var data = JSON.stringify(comment); 843 | xhr.open('POST', url); 844 | xhr.setRequestHeader('Content-type', 'application/json'); 845 | xhr.send(data); 846 | }; 847 | 848 | Controller.prototype.updateKifu = function(kifuID, newSGF, deletedNodes) { 849 | var self = this; 850 | var xhr = new XMLHttpRequest(); 851 | xhr.addEventListener('readystatechange', function() { 852 | // post initiated 853 | if (xhr.readyState === 1) { 854 | // disable save and cancel button 855 | self.html.save.disabled = true; 856 | self.html.cancel.disabled = true; 857 | // post successful 858 | } else if (xhr.readyState === 4 && xhr.status === 200) { 859 | // parse new kifu 860 | self.kifu = JSON.parse(xhr.responseText); 861 | // delete backup 862 | self.driverBackup = null; 863 | // exit edit mode 864 | self.isEditing = false; 865 | // update view 866 | self.updateNavEdit(); 867 | self.updateCommentList(); 868 | // re-enable save and cancel 869 | self.html.save.disabled = false; 870 | self.html.cancel.disabled = false; 871 | // post failed 872 | } else if (xhr.readyState === 4 && xhr.status !== 200) { 873 | // re-enable save and cancel 874 | self.html.save.disabled = false; 875 | self.html.cancel.disabled = false; 876 | // alert that save failed 877 | window.alert('Network Error: Save Failed'); 878 | throw new exceptions.NetworkError(1, "Kifu SGF Update Failed"); 879 | } 880 | }); 881 | 882 | // send post request to server 883 | var url = '/kifu/' + kifuID; 884 | var data = JSON.stringify({ 885 | 'sgf': newSGF, 886 | 'deletedNodes': deletedNodes, 887 | 'img': createThumbnail(newSGF, config.tq) 888 | }); 889 | xhr.open('UPDATE', url); 890 | xhr.setRequestHeader('Content-type', 'application/json'); 891 | xhr.send(data); 892 | }; 893 | 894 | Controller.prototype.deleteKifu = function(kifuID) { 895 | var self = this; 896 | var xhr = new XMLHttpRequest(); 897 | xhr.addEventListener('readystatechange', function() { 898 | // post initiated 899 | if (xhr.readyState === 1) { 900 | // disable delete button 901 | self.html.deleteKifu.disabled = true; 902 | // post successful 903 | } else if (xhr.readyState === 4 && xhr.status === 200) { 904 | window.location.replace(JSON.parse(xhr.responseText).redirect); 905 | // post failed 906 | } else if (xhr.readyState === 4 && xhr.status !== 200) { 907 | // re-enable delete button 908 | self.html.deleteKifu.disabled = false; 909 | throw new exceptions.NetworkError(2, "Kifu Deletion Failed"); 910 | } 911 | }); 912 | 913 | // send post request to server 914 | var url = '/kifu/' + kifuID; 915 | xhr.open('DELETE', url); 916 | xhr.setRequestHeader('Content-type', 'application/json'); 917 | xhr.send(); 918 | }; 919 | 920 | Controller.prototype.starKifu = function(kifuID) { 921 | var self = this; 922 | var xhr = new XMLHttpRequest(); 923 | xhr.addEventListener('readystatechange', function() { 924 | // if post successful 925 | if (xhr.readyState === 4 && xhr.status === 200) { 926 | self.starred = true; 927 | self.html.starKifu.parentNode.style.display = 'none'; 928 | self.html.unstarKifu.parentNode.style.display = 'inline-block'; 929 | // post failed 930 | } else if (xhr.readyState === 4 && xhr.status !== 200) { 931 | throw new exceptions.NetworkError(4, "Kifu Star/Unstar Failed"); 932 | } 933 | }); 934 | 935 | // send post request to server 936 | var url = '/star/kifu/' + kifuID; 937 | xhr.open('POST', url); 938 | xhr.setRequestHeader('Content-type', 'application/json'); 939 | xhr.send(); 940 | } 941 | 942 | Controller.prototype.unstarKifu = function(kifuID) { 943 | var self = this; 944 | var xhr = new XMLHttpRequest(); 945 | xhr.addEventListener('readystatechange', function() { 946 | // if post successful 947 | if (xhr.readyState === 4 && xhr.status === 200) { 948 | self.starred = false; 949 | self.html.starKifu.parentNode.style.display = 'inline-block'; 950 | self.html.unstarKifu.parentNode.style.display = 'none'; 951 | // post failed 952 | } else if (xhr.readyState === 4 && xhr.status !== 200) { 953 | throw new exceptions.NetworkError(4, "Kifu Star/Unstar Failed"); 954 | } 955 | }); 956 | 957 | // send post request to server 958 | var url = '/unstar/kifu/' + kifuID; 959 | xhr.open('POST', url); 960 | xhr.setRequestHeader('Content-type', 'application/json'); 961 | xhr.send(); 962 | } 963 | 964 | Controller.prototype.backupBoardCanvas = function() { 965 | this.driverBackup = this.boardCanvas.driver.clone(); 966 | }; 967 | 968 | Controller.prototype.restoreBoardCanvas = function() { 969 | if (!this.driverBackup) { 970 | return false; 971 | } 972 | 973 | // restore boardCanvas 974 | this.boardCanvas.driver = this.driverBackup; 975 | 976 | // update state 977 | this.autoPlayIntervalID = null; 978 | this.isEditing = false; 979 | this.driverBackup = null; // backup is deleted 980 | 981 | // update view 982 | this.updateNavEdit(); 983 | this.updateCommentList(); 984 | this.boardCanvas.render(); 985 | }; 986 | --------------------------------------------------------------------------------