├── 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 |
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 |
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 |
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 |
5 | {% if items|length != 0 %}
6 |
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 |
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 |
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 |
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 |
18 |
24 |
25 |
26 | {# flash messages #}
27 | {% with messages = get_flashed_messages() %}
28 | {% if messages %}
29 |
30 | {% for message in messages %}
31 | - {{ message }} Dismiss
32 | {% endfor %}
33 |
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 |
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 |
7 | {% for error in field.errors %}
8 | - {{ error }}
9 | {% endfor %}
10 |
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 |
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 | {{ tag }}>
41 | {% else %}
42 | <{{ tag }} class="fallback {{ entry_class }}">
43 | {{ unauth_fallback_text }}
44 | {{ tag }}>
45 | {% endif %}
46 | {% else %}
47 | {% if auth_status == 2 %}
48 | <{{ tag }} contentEditable="true" class="{{ entry_class }}">
49 | {{ entry }}
50 | {{ tag }}>
51 | {% else %}
52 | <{{ tag }} class="{{ entry_class }}">
53 | {{ entry }}
54 | {{ tag }}>
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 |
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 |
46 | {% include('kifu/kifu-navigation.html') %}
47 |
48 |
49 | {% include('kifu/kifu-edit.html') %}
50 |
51 |
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 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
49 |
50 |
51 |
52 |
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 |
--------------------------------------------------------------------------------
No Comments Found
45 | {% endif %} 46 |