From 8792e9fe1c7ab76c35f9a18d866880ba3da2c13e Mon Sep 17 00:00:00 2001 From: Jules Laplace Date: Mon, 15 Mar 2021 19:09:37 +0100 Subject: move frontend site folder. add video support --- cli/app/settings/app_cfg.py | 2 + cli/app/site/export.py | 125 ++++++++++++++++++++++++++++++++++++++++++++ cli/commands/site/export.py | 123 +------------------------------------------ 3 files changed, 129 insertions(+), 121 deletions(-) create mode 100644 cli/app/site/export.py (limited to 'cli') diff --git a/cli/app/settings/app_cfg.py b/cli/app/settings/app_cfg.py index 5fc4982..3865abd 100644 --- a/cli/app/settings/app_cfg.py +++ b/cli/app/settings/app_cfg.py @@ -63,6 +63,8 @@ DIR_STATIC = join(DIR_APP, 'static') HASH_TREE_DEPTH = 3 # for sha256 subdirs HASH_BRANCH_SIZE = 3 # for sha256 subdirs +DIR_PUBLIC_EXPORTS = os.getenv('DIR_PUBLIC_EXPORTS') or DIR_EXPORTS + # ----------------------------------------------------------------------------- # S3 storage diff --git a/cli/app/site/export.py b/cli/app/site/export.py new file mode 100644 index 0000000..d513286 --- /dev/null +++ b/cli/app/site/export.py @@ -0,0 +1,125 @@ +import click + +from app.settings import app_cfg +from app.utils.file_utils import load_text, write_json, write_text +from os.path import join +import os + +from app.sql.common import db, Session, Graph, Page, Tile +from distutils.dir_util import copy_tree + +def export_site(ctx, opt_graph_path, opt_output_dir): + """Export a graph""" + + # ------------------------------------------------ + # generate HTML for index and all pages + + session = Session() + graph = session.query(Graph).filter(Graph.path == opt_graph_path).first() + if graph is None: + print(f"Not a graph: {opt_graph_path}") + return + + # build everything here + graph_dir = os.path.abspath(join(opt_output_dir, graph.path)) + + # load site index + index_html = load_text(join(app_cfg.DIR_STATIC, 'site.html'), split=False) + index_html = index_html.replace('SITE_PATH', '/' + graph.path) + + # write site JSON data + site_data = { 'graph': sanitize_graph(graph.toSiteJSON()) } + write_json(site_data, join(graph_dir, 'index.json'), default=str, minify=False) + + # import custom css + site_css = load_text(join(app_cfg.DIR_STATIC, 'site.css'), split=False) + site_css = site_css.replace('SITE_PATH', '/' + graph.path) + write_text(site_css, join(graph_dir, 'site.css')) + copy_tree(join(app_cfg.DIR_STATIC, 'fonts'), join(graph_dir, 'static/fonts')) + copy_tree(join(app_cfg.DIR_STATIC, 'img'), join(graph_dir, 'static/img')) + + # write index file, redirects to homepage + home_page = site_data['graph']['home_page'] + if home_page is None: + print("Homepage not set! Shift-click a page on the graph to make it the homepage.") + return + write_text(f'', join(graph_dir, 'index.html')) + + index_path = "" + for page in graph.pages: + page_path = f'{graph.path}/{page.path}' + if page.id == graph.home_page_id: + index_path = page_path + print(f'/{page_path} [index]') + else: + print(f'/{page_path}') + write_index(graph, page, index_html, join(graph_dir, page.path, 'index.html')) + + build_javascript(graph_dir) + + print("Site export complete!") + print(f"Graph exported to: {graph_dir}") + +def build_javascript(graph_dir): + print("Building javascript...") + print(f'NODE_ENV=production node ./node_modules/webpack-cli/bin/cli.js --config ./webpack.config.site.js -o {graph_dir}/bundle.js') + os.chdir(app_cfg.DIR_PROJECT_ROOT) + os.system(f'NODE_ENV=production node ./node_modules/webpack-cli/bin/cli.js --config ./webpack.config.site.js -o {graph_dir}/bundle.js') + +def write_index(graph, page, index_html, fp_out): + if page is None: + page_title = graph.title + else: + page_title = page.title + index_html = index_html.replace('BUNDLE_PATH', join('/', graph.path, 'bundle.js')) + index_html = index_html.replace('PAGE_TITLE', page_title) + write_text(index_html, fp_out) + +def sanitize_graph(graph): + page_path_lookup = {} + page_lookup = {} + for page in graph['pages']: + page_path = join('/', graph['path'], page['path']) + if page_path in page_path_lookup: + print(f"/!\\ WARNING! Duplicate found of {page_path}") + else: + page_path_lookup[page['id']] = page_path + for page in graph['pages']: + sanitize_page(page) + if page['id'] == 12: + print(page) + for tile in page['tiles']: + if tile['target_page_id']: + if tile['target_page_id'] == -1: + tile['href'] = tile['settings']['external_link_url'] + elif tile['target_page_id'] > 0: + tile['href'] = page_path_lookup[tile['target_page_id']] + sanitize_tile(tile) + page_path = page_path_lookup[page['id']] + page_lookup[page_path] = page + # print(page_lookup['/asdf/testttt']) + graph['pages'] = page_lookup + graph['home_page'] = page_path_lookup[graph['home_page_id']] + return graph + +def sanitize_page(data): + if 'created_at' in data: + del data['created_at'] + if 'updated_at' in data: + del data['updated_at'] + if 'graph_id' in data: + del data['graph_id'] + +def sanitize_tile(data): + if 'created_at' in data: + del data['created_at'] + if 'updated_at' in data: + del data['updated_at'] + if 'username' in data: + del data['username'] + if 'graph_id' in data: + del data['graph_id'] + if 'page_id' in data: + del data['page_id'] + if 'target_page_id' in data: + del data['target_page_id'] diff --git a/cli/commands/site/export.py b/cli/commands/site/export.py index 0ba6a62..68773e9 100644 --- a/cli/commands/site/export.py +++ b/cli/commands/site/export.py @@ -1,9 +1,7 @@ import click from app.settings import app_cfg -from app.utils.file_utils import load_text, write_json, write_text -from os.path import join -import os +from app.site.export import export_site @click.command('info') @click.option('-g', '--graph', 'opt_graph_path', required=True, @@ -14,121 +12,4 @@ import os def cli(ctx, opt_graph_path, opt_output_dir): """Export a graph""" - # ------------------------------------------------ - # imports - - from app.sql.common import db, Session, Graph, Page, Tile - from distutils.dir_util import copy_tree - - # ------------------------------------------------ - # generate HTML for index and all pages - - session = Session() - graph = session.query(Graph).filter(Graph.path == opt_graph_path).first() - if graph is None: - print(f"Not a graph: {opt_graph_path}") - return - - # build everything here - graph_dir = os.path.abspath(join(opt_output_dir, graph.path)) - - # load site index - index_html = load_text(join(app_cfg.DIR_STATIC, 'site.html'), split=False) - index_html = index_html.replace('SITE_PATH', '/' + graph.path) - - # write site JSON data - site_data = { 'graph': sanitize_graph(graph.toSiteJSON()) } - write_json(site_data, join(graph_dir, 'index.json'), default=str, minify=False) - - # import custom css - site_css = load_text(join(app_cfg.DIR_STATIC, 'site.css'), split=False) - site_css = site_css.replace('SITE_PATH', '/' + graph.path) - write_text(site_css, join(graph_dir, 'site.css')) - copy_tree(join(app_cfg.DIR_STATIC, 'fonts'), join(graph_dir, 'static/fonts')) - copy_tree(join(app_cfg.DIR_STATIC, 'img'), join(graph_dir, 'static/img')) - - # write index file, redirects to homepage - home_page = site_data['graph']['home_page'] - if home_page is None: - print("Homepage not set! Shift-click a page on the graph to make it the homepage.") - return - write_text(f'', join(graph_dir, 'index.html')) - - index_path = "" - for page in graph.pages: - page_path = f'{graph.path}/{page.path}' - if page.id == graph.home_page_id: - index_path = page_path - print(f'/{page_path} [index]') - else: - print(f'/{page_path}') - write_index(graph, page, index_html, join(graph_dir, page.path, 'index.html')) - - # ------------------------------------------------ - # build javascript - - print("Building javascript...") - print(f'NODE_ENV=production node ./node_modules/webpack-cli/bin/cli.js --config ./webpack.config.site.js -o {graph_dir}/bundle.js') - os.chdir(app_cfg.DIR_PROJECT_ROOT) - os.system(f'NODE_ENV=production node ./node_modules/webpack-cli/bin/cli.js --config ./webpack.config.site.js -o {graph_dir}/bundle.js') - - print("Site export complete!") - print(f"Graph exported to: {graph_dir}") - -def write_index(graph, page, index_html, fp_out): - if page is None: - page_title = graph.title - else: - page_title = page.title - index_html = index_html.replace('BUNDLE_PATH', join('/', graph.path, 'bundle.js')) - index_html = index_html.replace('PAGE_TITLE', page_title) - write_text(index_html, fp_out) - -def sanitize_graph(graph): - page_path_lookup = {} - page_lookup = {} - for page in graph['pages']: - page_path = join('/', graph['path'], page['path']) - if page_path in page_path_lookup: - print(f"/!\\ WARNING! Duplicate found of {page_path}") - else: - page_path_lookup[page['id']] = page_path - for page in graph['pages']: - sanitize_page(page) - if page['id'] == 12: - print(page) - for tile in page['tiles']: - if tile['target_page_id']: - if tile['target_page_id'] == -1: - tile['href'] = tile['settings']['external_link_url'] - elif tile['target_page_id'] > 0: - tile['href'] = page_path_lookup[tile['target_page_id']] - sanitize_tile(tile) - page_path = page_path_lookup[page['id']] - page_lookup[page_path] = page - # print(page_lookup['/asdf/testttt']) - graph['pages'] = page_lookup - graph['home_page'] = page_path_lookup[graph['home_page_id']] - return graph - -def sanitize_page(data): - if 'created_at' in data: - del data['created_at'] - if 'updated_at' in data: - del data['updated_at'] - if 'graph_id' in data: - del data['graph_id'] - -def sanitize_tile(data): - if 'created_at' in data: - del data['created_at'] - if 'updated_at' in data: - del data['updated_at'] - if 'username' in data: - del data['username'] - if 'graph_id' in data: - del data['graph_id'] - if 'page_id' in data: - del data['page_id'] - if 'target_page_id' in data: - del data['target_page_id'] + export_site(opt_graph_path, opt_output_dir) \ No newline at end of file -- cgit v1.2.3-70-g09d2 From a9d86650f40a82a64d1fd8e0525c26422d314d3a Mon Sep 17 00:00:00 2001 From: Jules Laplace Date: Tue, 16 Mar 2021 16:38:07 +0100 Subject: make uploads like animism. start audio stuff --- cli/app/controllers/upload_controller.py | 75 +++++++++++++--------- cli/app/sql/models/upload.py | 17 ++--- .../202103161637_make_uploads_like_on_animism.py | 31 +++++++++ data_store/uploads/.gitkeep | 0 frontend/app/api/crud.upload.js | 14 +++- frontend/app/common/app.css | 10 +++ frontend/app/common/form.component.js | 2 +- .../app/views/graph/components/graph.header.js | 3 +- frontend/app/views/graph/components/page.form.js | 27 +++++++- frontend/app/views/page/components/page.header.js | 3 +- frontend/app/views/page/components/tile.edit.js | 4 +- frontend/app/views/page/components/tile.handle.js | 1 - static/uploads | 1 + 13 files changed, 137 insertions(+), 51 deletions(-) create mode 100644 cli/app/sql/versions/202103161637_make_uploads_like_on_animism.py create mode 100644 data_store/uploads/.gitkeep create mode 120000 static/uploads (limited to 'cli') diff --git a/cli/app/controllers/upload_controller.py b/cli/app/controllers/upload_controller.py index 86f9f29..94a7fd1 100644 --- a/cli/app/controllers/upload_controller.py +++ b/cli/app/controllers/upload_controller.py @@ -15,18 +15,22 @@ from app.server.decorators import APIError class UploadView(FlaskView): def index(self): """ - List all uploaded files. - - * Query string params: offset, limit, sort (id, date), order (asc, desc) + List all uploads """ session = Session() - uploads = session.query(Upload).all() - response = { + query = session.query(Upload) + graph_id = args.get('graph_id', default=None) + if graph_id is not None: + query = query.filter(Upload.graph_id == int(graph_id)) + + items = query.all() + + res = { 'status': 'ok', - 'res': [ upload.toJSON() for upload in uploads ], + 'res': [ item.toJSON() for item in items ], } session.close() - return jsonify(response) + return jsonify(res) def get(self, id): """ @@ -50,14 +54,31 @@ class UploadView(FlaskView): try: username = request.form.get('username') + # print(username) except: raise APIError('No username specified') - param_name = 'image' - if param_name not in request.files: - raise APIError('No file uploaded') + try: + tag = request.form.get('tag') + # print(tag) + except: + raise APIError('No tag specified') - file = request.files[param_name] + try: + graph_id = request.form.get('graph_id') + # print(graph_id) + except: + raise APIError('No graph_id specified') + + if 'image' in request.files: + file = request.files['image'] + # print(fn) + elif 'file' in request.files: + file = request.files['file'] + # print(request.form.get('__image_filename')) + # print(fn) + else: + raise APIError('No file uploaded') # get sha256 sha256 = sha256_stream(file) @@ -65,42 +86,34 @@ class UploadView(FlaskView): if ext == '.jpeg': ext = '.jpg' - # TODO: here check sha256 - # upload = Upload.query.get(id) - - if ext[1:] not in VALID_IMAGE_EXTS: - return jsonify({ 'status': 'error', 'error': 'Not a valid image' }) + ext = ext[1:] - # convert string of image data to uint8 file.seek(0) - nparr = np.fromstring(file.read(), np.uint8) - # decode image - try: - im = Image.fromarray(nparr) - except: - return jsonify({ 'status': 'error', 'error': 'Image parse error' }) + uploaded_im_fn = secure_filename(file.filename) + uploaded_im_abspath = os.path.join(app_cfg.DIR_UPLOADS, str(graph_id), tag) + uploaded_im_fullpath = os.path.join(uploaded_im_abspath, uploaded_im_fn) session = Session() upload = session.query(Upload).filter_by(sha256=sha256).first() if upload is not None: - print("Already uploaded image") + print("Already uploaded file") + if not os.path.exists(uploaded_im_fullpath): + # if we got in some weird state where the record wasnt deleted.... + os.makedirs(uploaded_im_abspath, exist_ok=True) + file.save(uploaded_im_fullpath) response = { 'status': 'ok', - 'notes': 'Image already uploaded', + 'notes': 'File already uploaded', 'res': upload.toJSON(), } session.close() return jsonify(response) - uploaded_im_fn = secure_filename(sha256 + ext) - uploaded_im_abspath = os.path.join(app_cfg.DIR_UPLOADS, sha256_tree(sha256)) - uploaded_im_fullpath = os.path.join(uploaded_im_abspath, uploaded_im_fn) - os.makedirs(uploaded_im_abspath, exist_ok=True) - nparr.tofile(uploaded_im_fullpath) + file.save(uploaded_im_fullpath) - upload = Upload(username=username, sha256=sha256, ext=ext) + upload = Upload(username=username, tag=tag, fn=uploaded_im_fn, sha256=sha256, ext=ext, graph_id=graph_id) session.add(upload) session.commit() response = { diff --git a/cli/app/sql/models/upload.py b/cli/app/sql/models/upload.py index 5863b07..87f758a 100644 --- a/cli/app/sql/models/upload.py +++ b/cli/app/sql/models/upload.py @@ -14,31 +14,28 @@ class Upload(Base): """Table for storing references to various media""" __tablename__ = 'upload' id = Column(Integer, primary_key=True) + graph_id = Column(Integer) sha256 = Column(String(256), nullable=False) fn = Column(String(256), nullable=False) ext = Column(String(4, convert_unicode=True), nullable=False) + tag = Column(String(64, convert_unicode=True), nullable=True) username = Column(String(16, convert_unicode=True), nullable=False) created_at = Column(UtcDateTime(), default=utcnow()) def toJSON(self): return { 'id': self.id, + 'graph_id': self.graph_id, 'sha256': self.sha256, 'fn': self.fn, 'ext': self.ext, + 'tag': self.tag, 'username': self.username, 'url': self.url(), 'created_at': self.created_at, } - def filename(self): - return "{}{}".format(self.fn) - - def filepath(self): - return join(app_cfg.DIR_UPLOADS, sha256_tree(self.sha256)) - - def fullpath(self): - return join(self.filepath(), self.filename()) - def url(self): - return join(app_cfg.URL_UPLOADS, sha256_tree(self.sha256), self.filename()) + if self.tag: + return join('/static/data_store/uploads', str(self.graph_id), self.tag, self.fn) + return join('/static/data_store/uploads', str(self.graph_id), self.fn) diff --git a/cli/app/sql/versions/202103161637_make_uploads_like_on_animism.py b/cli/app/sql/versions/202103161637_make_uploads_like_on_animism.py new file mode 100644 index 0000000..18bf0bc --- /dev/null +++ b/cli/app/sql/versions/202103161637_make_uploads_like_on_animism.py @@ -0,0 +1,31 @@ +"""make uploads like on animism + +Revision ID: 645f315e651d +Revises: d929da3e398b +Create Date: 2021-03-16 16:37:08.985792 + +""" +from alembic import op +import sqlalchemy as sa +import sqlalchemy_utc + + +# revision identifiers, used by Alembic. +revision = '645f315e651d' +down_revision = 'd929da3e398b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('upload', sa.Column('graph_id', sa.Integer(), nullable=True)) + op.add_column('upload', sa.Column('tag', sa.String(length=64, _expect_unicode=True), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('upload', 'tag') + op.drop_column('upload', 'graph_id') + # ### end Alembic commands ### diff --git a/data_store/uploads/.gitkeep b/data_store/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/app/api/crud.upload.js b/frontend/app/api/crud.upload.js index 8c1b265..2837dd4 100644 --- a/frontend/app/api/crud.upload.js +++ b/frontend/app/api/crud.upload.js @@ -1,4 +1,5 @@ import { as_type } from 'app/api/crud.types' +import { session } from 'app/session' export function crud_upload(type, data, dispatch) { return new Promise( (resolve, reject) => { @@ -6,9 +7,17 @@ export function crud_upload(type, data, dispatch) { const { id } = data const fd = new FormData() + if (!data.tag) { + data.tag = 'misc' + } Object.keys(data).forEach(key => { - if (key !== 'id') { + if (key.indexOf('__') !== -1) return + if (key === 'id') return + const fn_key = `__${key}_filename` + if (fn_key in data) { + fd.append(key, data[key], data[fn_key]) + } else { fd.append(key, data[key]) } }) @@ -23,12 +32,11 @@ export function crud_upload(type, data, dispatch) { xhr.addEventListener("error", uploadFailed, false) xhr.addEventListener("abort", uploadCancelled, false) xhr.open("POST", url) + xhr.setRequestHeader("Authorization", "Bearer " + session.get("access_token")) xhr.send(fd) dispatch && dispatch({ type: as_type(type, 'upload_loading')}) - let complete = false - function uploadProgress (e) { if (e.lengthComputable) { const percent = Math.round(e.loaded * 100 / e.total) || 0 diff --git a/frontend/app/common/app.css b/frontend/app/common/app.css index d9f9946..2e9dc4e 100644 --- a/frontend/app/common/app.css +++ b/frontend/app/common/app.css @@ -147,6 +147,16 @@ header a:active { header a.navbar-brand { font-size: .8rem; } +header .arrow { + padding: 0.5rem 0.5rem 0.5rem 0.5rem; + margin-left: -0.5rem; + margin-right: 0.25rem; + transition: background 0.2s; + border-radius: 4px; +} +header .arrow:hover { + background: rgba(0,0,255,0.5); +} header .username { cursor: pointer; diff --git a/frontend/app/common/form.component.js b/frontend/app/common/form.component.js index cf3e466..927b89d 100644 --- a/frontend/app/common/form.component.js +++ b/frontend/app/common/form.component.js @@ -76,7 +76,7 @@ export const Checkbox = props => ( type="checkbox" name={props.name} value={1} - checked={props.checked} + checked={!!props.checked} onChange={(e) => props.onChange(props.name, e.target.checked)} /> {props.label} diff --git a/frontend/app/views/graph/components/graph.header.js b/frontend/app/views/graph/components/graph.header.js index 46ad962..b969400 100644 --- a/frontend/app/views/graph/components/graph.header.js +++ b/frontend/app/views/graph/components/graph.header.js @@ -9,7 +9,8 @@ function GraphHeader(props) { return (
- {props.site.siteTitle} + {"◁ "} + {props.site.siteTitle}
diff --git a/frontend/app/views/graph/components/page.form.js b/frontend/app/views/graph/components/page.form.js index 8fc00b0..2c283aa 100644 --- a/frontend/app/views/graph/components/page.form.js +++ b/frontend/app/views/graph/components/page.form.js @@ -3,7 +3,7 @@ import { Link } from 'react-router-dom' import { session } from 'app/session' -import { TextInput, ColorInput, LabelDescription, TextArea, Checkbox, SubmitButton, Loader } from 'app/common' +import { TextInput, ColorInput, Checkbox, LabelDescription, TextArea, SubmitButton, Loader } from 'app/common' const newPage = (data) => ({ path: '', @@ -14,6 +14,8 @@ const newPage = (data) => ({ x: 0.05, y: 0.05, background_color: '#000000', + audio: "", + restartAudio: false, }, ...data, }) @@ -76,6 +78,10 @@ export default class PageForm extends Component { handleSettingsChange(e) { const { name, value } = e.target + this.handleSettingsSelect(name, value) + } + + handleSettingsSelect(name, value) { this.setState({ data: { ...this.state.data, @@ -147,7 +153,7 @@ export default class PageForm extends Component { autoComplete="off" /> + + + +
- {props.site.siteTitle} + {"◁"} + {props.site.siteTitle}
diff --git a/frontend/app/views/page/components/tile.edit.js b/frontend/app/views/page/components/tile.edit.js index 2ea09d1..cae9f73 100644 --- a/frontend/app/views/page/components/tile.edit.js +++ b/frontend/app/views/page/components/tile.edit.js @@ -29,7 +29,9 @@ class TileEdit extends Component { load() { const { currentEditTileId } = this.props.page.editor - const tile = this.props.page.show.res.tiles.filter(tile => tile.id === currentEditTileId)[0] + const { tiles } = this.props.page.show.res + if (!tiles) return + const tile = tiles.filter(tile => tile.id === currentEditTileId)[0] console.log('edit', currentEditTileId) this.setState({ tile }) } diff --git a/frontend/app/views/page/components/tile.handle.js b/frontend/app/views/page/components/tile.handle.js index 9331cb3..f47c3cd 100644 --- a/frontend/app/views/page/components/tile.handle.js +++ b/frontend/app/views/page/components/tile.handle.js @@ -151,7 +151,6 @@ const generateTransform = (tile, box) => { if (xalign === 'center') { transform.push('translateX(-50%)') } - console.log(units) // if (x % 2 == 1) x += 0.5 // if (y % 2 == 1) y += 0.5 transform.push('translateX(' + x + units + ')') diff --git a/static/uploads b/static/uploads new file mode 120000 index 0000000..79c5899 --- /dev/null +++ b/static/uploads @@ -0,0 +1 @@ +../data_store/uploads \ No newline at end of file -- cgit v1.2.3-70-g09d2 From 1cfe96ca6ef5c54eadd986c951dade0f56d72440 Mon Sep 17 00:00:00 2001 From: Jules Laplace Date: Tue, 16 Mar 2021 16:54:28 +0100 Subject: migrating to mysql --- cli/app/settings/app_cfg.py | 6 ++ cli/app/sql/common.py | 23 ++-- cli/app/sql/env.py | 2 +- cli/app/sql/models/graph.py | 3 + cli/app/sql/models/upload.py | 4 +- .../202103161645_add_foreign_key_constraint.py | 29 +++++ cli/commands/admin/migrate_to_mysql.py | 120 +++++++++++++++++++++ docs/migrate_to_mysql.md | 1 + env-sample | 7 ++ frontend/app/views/index/containers/graph.index.js | 1 + 10 files changed, 181 insertions(+), 15 deletions(-) create mode 100644 cli/app/sql/versions/202103161645_add_foreign_key_constraint.py create mode 100644 cli/commands/admin/migrate_to_mysql.py create mode 100644 docs/migrate_to_mysql.md (limited to 'cli') diff --git a/cli/app/settings/app_cfg.py b/cli/app/settings/app_cfg.py index 3865abd..af6cf89 100644 --- a/cli/app/settings/app_cfg.py +++ b/cli/app/settings/app_cfg.py @@ -33,6 +33,7 @@ load_dotenv(dotenv_path=fp_env) CLICK_GROUPS = { # 'process': 'commands/process', 'site': 'commands/site', + 'admin': 'commands/admin', 'db': '', 'flask': '', } @@ -65,6 +66,11 @@ HASH_BRANCH_SIZE = 3 # for sha256 subdirs DIR_PUBLIC_EXPORTS = os.getenv('DIR_PUBLIC_EXPORTS') or DIR_EXPORTS +# ----------------------------------------------------------------------------- +# Database +# ----------------------------------------------------------------------------- + +USE_SQLITE = os.getenv("USE_SQLITE") == "True" # ----------------------------------------------------------------------------- # S3 storage diff --git a/cli/app/sql/common.py b/cli/app/sql/common.py index c8bd557..8e1d2b3 100644 --- a/cli/app/sql/common.py +++ b/cli/app/sql/common.py @@ -2,7 +2,6 @@ import os import glob import time -# import mysql.connector from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.ext.declarative import declarative_base @@ -11,16 +10,16 @@ from flask_sqlalchemy import SQLAlchemy from app.settings import app_cfg -# connection_url = "mysql+mysqlconnector://{}:{}@{}/{}?charset=utf8mb4".format( -# os.getenv("DB_USER"), -# os.getenv("DB_PASS"), -# os.getenv("DB_HOST"), -# os.getenv("DB_NAME") -# ) - -os.makedirs(app_cfg.DIR_DATABASE, exist_ok=True) - -connection_url = "sqlite:///{}".format(os.path.join(app_cfg.DIR_DATABASE, 'swimmer.sqlite3')) +if app_cfg.USE_SQLITE: + os.makedirs(app_cfg.DIR_DATABASE, exist_ok=True) + connection_url = "sqlite:///{}".format(os.path.join(app_cfg.DIR_DATABASE, 'swimmer.sqlite3')) +else: + connection_url = "mysql+pymysql://{}:{}@{}/{}?charset=utf8mb4".format( + os.getenv("DB_USER"), + os.getenv("DB_PASS"), + os.getenv("DB_HOST"), + os.getenv("DB_NAME") + ) engine = create_engine(connection_url, encoding="utf-8", pool_recycle=3600) @@ -31,7 +30,7 @@ Base.metadata.bind = engine db = SQLAlchemy() # include the models in reverse dependency order, so relationships work +from app.sql.models.upload import Upload from app.sql.models.tile import Tile from app.sql.models.page import Page from app.sql.models.graph import Graph -from app.sql.models.upload import Upload diff --git a/cli/app/sql/env.py b/cli/app/sql/env.py index 7753565..3e015b5 100644 --- a/cli/app/sql/env.py +++ b/cli/app/sql/env.py @@ -14,10 +14,10 @@ config.set_main_option("sqlalchemy.url", connection_url) target_metadata = Base.metadata # include the models in reverse dependency order, so relationships work +from app.sql.models.upload import Upload from app.sql.models.tile import Tile from app.sql.models.page import Page from app.sql.models.graph import Graph -from app.sql.models.upload import Upload def run_migrations_offline(): """Run migrations in 'offline' mode. diff --git a/cli/app/sql/models/graph.py b/cli/app/sql/models/graph.py index 8e068a0..08f4d3c 100644 --- a/cli/app/sql/models/graph.py +++ b/cli/app/sql/models/graph.py @@ -23,6 +23,7 @@ class Graph(Base): updated_at = Column(UtcDateTime(), onupdate=utcnow()) pages = relationship('Page', lazy='dynamic') + uploads = relationship('Upload', lazy='dynamic') def toJSON(self): return { @@ -40,11 +41,13 @@ class Graph(Base): def toFullJSON(self): data = self.toJSON() data['pages'] = [ page.toLinkJSON() for page in self.pages ] + data['uploads'] = [ upload.toJSON() for upload in self.uploads ] return data def toSiteJSON(self): data = self.toJSON() data['pages'] = [ page.toFullJSON() for page in self.pages ] + data['uploads'] = [ upload.toJSON() for upload in self.uploads ] return data class GraphForm(ModelForm): diff --git a/cli/app/sql/models/upload.py b/cli/app/sql/models/upload.py index 87f758a..30e53dc 100644 --- a/cli/app/sql/models/upload.py +++ b/cli/app/sql/models/upload.py @@ -1,4 +1,4 @@ -from sqlalchemy import create_engine, Table, Column, String, Integer, DateTime +from sqlalchemy import create_engine, Table, Column, ForeignKey, String, Integer, DateTime import sqlalchemy.sql.functions as func from sqlalchemy_utc import UtcDateTime, utcnow from wtforms_alchemy import ModelForm @@ -14,7 +14,7 @@ class Upload(Base): """Table for storing references to various media""" __tablename__ = 'upload' id = Column(Integer, primary_key=True) - graph_id = Column(Integer) + graph_id = Column(Integer, ForeignKey('graph.id'), nullable=True) sha256 = Column(String(256), nullable=False) fn = Column(String(256), nullable=False) ext = Column(String(4, convert_unicode=True), nullable=False) diff --git a/cli/app/sql/versions/202103161645_add_foreign_key_constraint.py b/cli/app/sql/versions/202103161645_add_foreign_key_constraint.py new file mode 100644 index 0000000..673f9e4 --- /dev/null +++ b/cli/app/sql/versions/202103161645_add_foreign_key_constraint.py @@ -0,0 +1,29 @@ +"""add foreign key constraint + +Revision ID: 3f7df6bf63b8 +Revises: 645f315e651d +Create Date: 2021-03-16 16:45:39.455892 + +""" +from alembic import op +import sqlalchemy as sa +import sqlalchemy_utc + + +# revision identifiers, used by Alembic. +revision = '3f7df6bf63b8' +down_revision = '645f315e651d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_foreign_key(None, 'upload', 'graph', ['graph_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'upload', type_='foreignkey') + # ### end Alembic commands ### diff --git a/cli/commands/admin/migrate_to_mysql.py b/cli/commands/admin/migrate_to_mysql.py new file mode 100644 index 0000000..32ff7cf --- /dev/null +++ b/cli/commands/admin/migrate_to_mysql.py @@ -0,0 +1,120 @@ +import click +import os +import glob +import time + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.ext.declarative import declarative_base + +from flask_sqlalchemy import SQLAlchemy + +from app.settings import app_cfg + +@click.command('migrate_to_mysql') +@click.pass_context +def cli(ctx): + """ + - Create connections to both databases + - For each table, for each row, insert from one to the other + """ + mysql_session, mysql_base = make_mysql_base() + sqlite_session, sqlite_base = make_sqlite3_base() + mysql_classes = make_classes(mysql_base) + sqlite_classes = make_classes(sqlite_base) + + for mysql_class, sqlite_class in zip(mysql_classes, sqlite_classes): + sqlite_objs = sqlite_session.query(sqlite_class).order_by(sqlite_class.id).all() + for sqlite_obj in sqlite_objs: + mysql_obj = mysql_class() + for column in sqlite_class.__table__.columns: + table_name, column_name = str(column).split(".") + # print(f"{table_name} => {column_name}") + # if column_name != 'id': + setattr(mysql_obj, column_name, getattr(sqlite_obj, column_name)) + mysql_session.add(mysql_obj) + mysql_session.commit() + +def make_mysql_base(): + """Make a Mysql connection""" + connection_url = "mysql+pymysql://{}:{}@{}/{}?charset=utf8mb4".format( + os.getenv("DB_USER"), + os.getenv("DB_PASS"), + os.getenv("DB_HOST"), + os.getenv("DB_NAME") + ) + return make_base(connection_url) + +def make_sqlite3_base(): + """Make a SQLite3 connection""" + connection_url = "sqlite:///{}".format(os.path.join(app_cfg.DIR_DATABASE, 'animism.sqlite3')) + return make_base(connection_url) + +def make_base(connection_url): + """Make a connection base from a connection URL""" + engine = create_engine(connection_url, encoding="utf-8", pool_recycle=3600) + Session = sessionmaker(bind=engine) + Base = declarative_base() + Base.metadata.bind = engine + db = SQLAlchemy() + return Session(), Base + +def make_classes(Base): + """Make classes from a base""" + + from sqlalchemy import create_engine, Table, Column, Text, String, Integer, \ + Boolean, Float, DateTime, JSON, ForeignKey + from sqlalchemy_utc import UtcDateTime, utcnow + + class Upload(Base): + """Table for storing references to various media""" + __tablename__ = 'upload' + id = Column(Integer, primary_key=True) + graph_id = Column(Integer, ForeignKey('graph.id'), nullable=True) + sha256 = Column(String(256), nullable=False) + fn = Column(String(256), nullable=False) + ext = Column(String(4, convert_unicode=True), nullable=False) + tag = Column(String(64, convert_unicode=True), nullable=True) + username = Column(String(16, convert_unicode=True), nullable=False) + created_at = Column(UtcDateTime(), default=utcnow()) + + class Tile(Base): + """Table for storing references to tiles""" + __tablename__ = 'tile' + id = Column(Integer, primary_key=True) + graph_id = Column(Integer, ForeignKey('graph.id'), nullable=True) + page_id = Column(Integer, ForeignKey('page.id'), nullable=True) + target_page_id = Column(Integer, ForeignKey('page.id'), nullable=True) + type = Column(String(16, convert_unicode=True), nullable=False) + sort_order = Column(Integer, default=0) + settings = Column(JSON, default={}, nullable=True) + created_at = Column(UtcDateTime(), default=utcnow()) + updated_at = Column(UtcDateTime(), onupdate=utcnow()) + + class Page(Base): + """Table for storing references to pages""" + __tablename__ = 'page' + id = Column(Integer, primary_key=True) + graph_id = Column(Integer, ForeignKey('graph.id'), nullable=True) + path = Column(String(64, convert_unicode=True), nullable=False) + title = Column(String(64, convert_unicode=True), nullable=False) + username = Column(String(32, convert_unicode=True), nullable=False) + description = Column(Text(convert_unicode=True), nullable=False) + settings = Column(JSON, default={}, nullable=True) + created_at = Column(UtcDateTime(), default=utcnow()) + updated_at = Column(UtcDateTime(), onupdate=utcnow()) + + class Graph(Base): + """Table for storing references to graphs""" + __tablename__ = 'graph' + id = Column(Integer, primary_key=True) + home_page_id = Column(Integer, nullable=True) + path = Column(String(64, convert_unicode=True), nullable=False) + title = Column(String(64, convert_unicode=True), nullable=False) + username = Column(String(32, convert_unicode=True), nullable=False) + description = Column(Text(convert_unicode=True), nullable=False) + settings = Column(JSON, default={}, nullable=True) + created_at = Column(UtcDateTime(), default=utcnow()) + updated_at = Column(UtcDateTime(), onupdate=utcnow()) + + return [ Upload, Graph, Page, Tile ] diff --git a/docs/migrate_to_mysql.md b/docs/migrate_to_mysql.md new file mode 100644 index 0000000..45fe7de --- /dev/null +++ b/docs/migrate_to_mysql.md @@ -0,0 +1 @@ +migrate_to_mysql.md diff --git a/env-sample b/env-sample index 1a23be3..83c36e4 100644 --- a/env-sample +++ b/env-sample @@ -2,3 +2,10 @@ FLASK_RUN_PORT=7500 SERVER_NAME=swim.neural.garden HTTP_EXTERNAL_HOST=https://swim.neural.garden +USE_SQLITE=False + +DB_USER=swimmer +DB_PASS= +DB_HOST=localhost +DB_NAME=swimmer + diff --git a/frontend/app/views/index/containers/graph.index.js b/frontend/app/views/index/containers/graph.index.js index 91098a7..bf3d75e 100644 --- a/frontend/app/views/index/containers/graph.index.js +++ b/frontend/app/views/index/containers/graph.index.js @@ -11,6 +11,7 @@ class GraphIndex extends Component { componentDidMount() { actions.graph.index() } + render() { const { index } = this.props // console.log(this.props) -- cgit v1.2.3-70-g09d2 From 901beb4df2c074ba54fedc91dd6e780cebe093d1 Mon Sep 17 00:00:00 2001 From: Jules Laplace Date: Tue, 16 Mar 2021 17:04:54 +0100 Subject: migrate to mysql --- cli/commands/admin/migrate_to_mysql.py | 4 ++-- docs/migrate_to_mysql.md | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) (limited to 'cli') diff --git a/cli/commands/admin/migrate_to_mysql.py b/cli/commands/admin/migrate_to_mysql.py index 32ff7cf..0ad9929 100644 --- a/cli/commands/admin/migrate_to_mysql.py +++ b/cli/commands/admin/migrate_to_mysql.py @@ -47,7 +47,7 @@ def make_mysql_base(): def make_sqlite3_base(): """Make a SQLite3 connection""" - connection_url = "sqlite:///{}".format(os.path.join(app_cfg.DIR_DATABASE, 'animism.sqlite3')) + connection_url = "sqlite:///{}".format(os.path.join(app_cfg.DIR_DATABASE, 'swimmer.sqlite3')) return make_base(connection_url) def make_base(connection_url): @@ -117,4 +117,4 @@ def make_classes(Base): created_at = Column(UtcDateTime(), default=utcnow()) updated_at = Column(UtcDateTime(), onupdate=utcnow()) - return [ Upload, Graph, Page, Tile ] + return [ Graph, Page, Tile, Upload ] diff --git a/docs/migrate_to_mysql.md b/docs/migrate_to_mysql.md index 45fe7de..f414a1e 100644 --- a/docs/migrate_to_mysql.md +++ b/docs/migrate_to_mysql.md @@ -1 +1,17 @@ -migrate_to_mysql.md +# Migrate to MySQL + +1. Create a MySQL user and database + +``` +CREATE USER 'swimmer'@'localhost' IDENTIFIED BY 'swimmer'; +CREATE DATABASE swimmer; +GRANT ALL PRIVILEGES ON swimmer.* TO 'swimmer'@'localhost'; +``` + +2. Set up the `.env` which should disable SQLite. + +3. Run `./cli.py db upgrade head` + +4. Run `./cli.py admin migrate_to_mysql` + +5. Restart the server. -- cgit v1.2.3-70-g09d2 From 15d9d864b539e221c6494b3535abef724517f207 Mon Sep 17 00:00:00 2001 From: Jules Laplace Date: Tue, 16 Mar 2021 18:19:26 +0100 Subject: uploading audio files and displaying them in a list --- cli/app/sql/models/upload.py | 4 +- frontend/app/types.js | 1 + frontend/app/views/graph/components/audio.list.js | 150 +++++++++++++++++++++ .../app/views/graph/components/graph.header.js | 1 + frontend/app/views/graph/graph.actions.js | 4 + frontend/app/views/graph/graph.container.js | 2 + frontend/app/views/graph/graph.css | 53 ++++++++ frontend/app/views/graph/graph.reducer.js | 31 +++++ static/img/icons_pause_white.svg | 6 + static/img/icons_play_white.svg | 6 + 10 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 frontend/app/views/graph/components/audio.list.js create mode 100755 static/img/icons_pause_white.svg create mode 100755 static/img/icons_play_white.svg (limited to 'cli') diff --git a/cli/app/sql/models/upload.py b/cli/app/sql/models/upload.py index 30e53dc..d9307ff 100644 --- a/cli/app/sql/models/upload.py +++ b/cli/app/sql/models/upload.py @@ -37,5 +37,5 @@ class Upload(Base): def url(self): if self.tag: - return join('/static/data_store/uploads', str(self.graph_id), self.tag, self.fn) - return join('/static/data_store/uploads', str(self.graph_id), self.fn) + return join('/static/uploads', str(self.graph_id), self.tag, self.fn) + return join('/static/uploads', str(self.graph_id), self.fn) diff --git a/frontend/app/types.js b/frontend/app/types.js index 7120a91..19d1e69 100644 --- a/frontend/app/types.js +++ b/frontend/app/types.js @@ -6,6 +6,7 @@ export const graph = crud_type('graph', [ 'show_add_page_form', 'hide_add_page_form', 'toggle_add_page_form', 'show_edit_page_form', 'hide_edit_page_form', 'toggle_edit_page_form', 'update_graph_page', + 'toggle_audio_list', ]) export const page = crud_type('page', [ diff --git a/frontend/app/views/graph/components/audio.list.js b/frontend/app/views/graph/components/audio.list.js new file mode 100644 index 0000000..bd8fe16 --- /dev/null +++ b/frontend/app/views/graph/components/audio.list.js @@ -0,0 +1,150 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' +import { connect } from 'react-redux' + +import { history } from 'app/store' +import actions from 'app/actions' + +class AudioList extends Component { + state = { + playing: false, + play_id: -1, + } + + constructor(props) { + super(props) + this.toggleAudio = this.toggleAudio.bind(this) + this.upload = this.upload.bind(this) + this.audioDidEnd = this.audioDidEnd.bind(this) + } + + componentDidMount() { + this.audioElement = document.createElement('audio') + this.audioElement.addEventListener('ended', this.audioDidEnd) + } + + componentWillUnmount() { + this.audioElement.removeEventListener('ended', this.audioDidEnd) + this.audioElement.pause() + this.audioElement = null + } + + audioDidEnd() { + this.setState({ playing: false }) + } + + upload(e) { + e.preventDefault() + document.body.className = '' + const files = e.dataTransfer ? e.dataTransfer.files : e.target.files + let i + if (!files.length) return + Array.from(files).forEach(file => this.uploadTaggedFile(file, 'audio', file.filename)) + } + + uploadTaggedFile(file, tag, fn) { + return new Promise((resolve, reject) => { + this.setState({ status: "Uploading " + tag + "..." }) + const uploadData = { + tag, + file, + __file_filename: fn, + graph_id: this.props.graph.id, + username: 'swimmer', + } + // console.log(uploadData) + return actions.upload.upload(uploadData).then(data => { + // console.log(data) + resolve({ + ...data.res, + }) + }) + }) + } + + destroyFile(upload) { + return new Promise((resolve, reject) => { + actions.upload.destroy(upload) + .then(() => { + console.log('Destroy successful') + resolve() + }) + .catch(() => { + console.log('Error deleting the file') + reject() + }) + }) + } + + toggleAudio(upload) { + console.log(upload) + let playing = false + if (this.state.play_id === upload.id && this.state.playing) { + this.audioElement.pause() + } else { + this.audioElement.src = upload.url + this.audioElement.currentTime = 0 + this.audioElement.play() + playing = true + } + this.setState({ + playing, + play_id: upload.id, + }) + } + + render() { + const { playing, play_id } = this.state + const { graph } = this.props + // console.log(graph.uploads) + console.log(playing, play_id) + return ( +
+
+ + +
+ {graph.uploads.map(upload => ( +
this.toggleAudio(upload)} > + +
+
{unslugify(upload.fn)}
+
+
+ ))} +
+ ) + } +} + +const unslugify = fn => fn.replace(/-/g, ' ').replace(/_/g, ' ').replace('.mp3', '') + +const mapStateToProps = state => ({ + graph: state.graph.show.res, +}) + +const mapDispatchToProps = dispatch => ({ +}) + +export default connect(mapStateToProps, mapDispatchToProps)(AudioList) + + +/* + - upload new audio file + */ \ No newline at end of file diff --git a/frontend/app/views/graph/components/graph.header.js b/frontend/app/views/graph/components/graph.header.js index b969400..0766580 100644 --- a/frontend/app/views/graph/components/graph.header.js +++ b/frontend/app/views/graph/components/graph.header.js @@ -14,6 +14,7 @@ function GraphHeader(props) {
+
) diff --git a/frontend/app/views/graph/graph.actions.js b/frontend/app/views/graph/graph.actions.js index a24ccc2..eba3f92 100644 --- a/frontend/app/views/graph/graph.actions.js +++ b/frontend/app/views/graph/graph.actions.js @@ -25,6 +25,10 @@ export const toggleEditPageForm = () => dispatch => { dispatch({ type: types.graph.toggle_edit_page_form }) } +export const toggleAudioList = () => dispatch => { + dispatch({ type: types.graph.toggle_audio_list }) +} + export const updateGraphPage = page => dispatch => { dispatch({ type: types.graph.update_graph_page, page }) } diff --git a/frontend/app/views/graph/graph.container.js b/frontend/app/views/graph/graph.container.js index 9e354fc..34c3d9d 100644 --- a/frontend/app/views/graph/graph.container.js +++ b/frontend/app/views/graph/graph.container.js @@ -15,6 +15,7 @@ import PageEdit from './components/page.edit' import GraphHeader from './components/graph.header' import GraphEditor from './components/graph.editor' +import AudioList from './components/audio.list' class GraphContainer extends Component { componentDidMount() { @@ -63,6 +64,7 @@ class GraphContainer extends Component {
{this.props.graph.editor.addingPage && } {this.props.graph.editor.editingPage && } + {this.props.graph.editor.showingAudio && }
diff --git a/frontend/app/views/graph/graph.css b/frontend/app/views/graph/graph.css index 2805cb0..c6ef115 100644 --- a/frontend/app/views/graph/graph.css +++ b/frontend/app/views/graph/graph.css @@ -146,6 +146,59 @@ width: 5.5rem; } +/* Upload area */ + +.box .uploadButton { + position: relative; + display: flex; + justify-content: center; + align-items: center; + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} +.uploadButton input[type=file] { + position: absolute; + top: 0; left: 0; + width: 100%; height: 100%; +} +.audioList .audioItem { + display: flex; + justify-content: flex-start; + align-items: center; + cursor: pointer; + padding: 0.125rem 0; +} +.audioList .playButton { + background: transparent; + border: 0; + width: 1.5rem; + height: 1.5rem; + margin-right: 0.5rem; + opacity: 0.8; +} +.audioList .title { + display: flex; + justify-content: flex-start; + align-items: center; + overflow: hidden; + flex: 1; +} +.audioList .title div { + overflow: hidden; + text-overflow: ellipsis; + white-space: pre; + width: 100%; +} +.audioList .audioItem:hover { + background: rgba(255,255,255,0.2); +} +.audioList .audioItem:hover .title { + color: #fff; +} +.audioList .audioItem:hover .playButton { + opacity: 1.0; +} + /* Graph handles */ .handle { diff --git a/frontend/app/views/graph/graph.reducer.js b/frontend/app/views/graph/graph.reducer.js index 6be5089..30049b5 100644 --- a/frontend/app/views/graph/graph.reducer.js +++ b/frontend/app/views/graph/graph.reducer.js @@ -7,6 +7,7 @@ const initialState = crudState('graph', { editor: { addingPage: false, editingPage: false, + showingAudio: false, }, options: { } @@ -36,6 +37,19 @@ export default function graphReducer(state = initialState, action) { } } + case types.upload.upload_complete: + console.log(action) + return { + ...state, + show: { + ...state.show, + res: { + ...state.show.res, + uploads: state.show.res.uploads.concat(action.data.res) + } + } + } + case types.graph.show_add_page_form: return { ...state, @@ -43,6 +57,7 @@ export default function graphReducer(state = initialState, action) { ...state.editor, addingPage: true, editingPage: false, + showingAudio: false, } } @@ -52,6 +67,7 @@ export default function graphReducer(state = initialState, action) { editor: { ...state.editor, addingPage: false, + showingAudio: false, } } @@ -62,6 +78,7 @@ export default function graphReducer(state = initialState, action) { ...state.editor, addingPage: !state.editor.addingPage, editingPage: false, + showingAudio: false, } } @@ -72,6 +89,7 @@ export default function graphReducer(state = initialState, action) { ...state.editor, addingPage: false, editingPage: true, + showingAudio: false, } } @@ -81,6 +99,7 @@ export default function graphReducer(state = initialState, action) { editor: { ...state.editor, editingPage: false, + showingAudio: false, } } @@ -91,6 +110,18 @@ export default function graphReducer(state = initialState, action) { ...state.editor, addingPage: false, editingPage: !state.editor.editingPage, + showingAudio: false, + } + } + + case types.graph.toggle_audio_list: + return { + ...state, + editor: { + ...state.editor, + addingPage: false, + editingPage: false, + showingAudio: !state.editor.showingAudio, } } diff --git a/static/img/icons_pause_white.svg b/static/img/icons_pause_white.svg new file mode 100755 index 0000000..59c7e60 --- /dev/null +++ b/static/img/icons_pause_white.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/static/img/icons_play_white.svg b/static/img/icons_play_white.svg new file mode 100755 index 0000000..78ff002 --- /dev/null +++ b/static/img/icons_play_white.svg @@ -0,0 +1,6 @@ + + + + + -- cgit v1.2.3-70-g09d2 From d165a0727e42349d935ab3ee287242f1e5029742 Mon Sep 17 00:00:00 2001 From: Jules Laplace Date: Wed, 17 Mar 2021 18:11:26 +0100 Subject: frontend. export/view button. interactivity sanity check --- cli/app/controllers/graph_controller.py | 11 +- cli/app/server/demo.py | 48 + cli/app/server/web.py | 2 +- cli/app/settings/app_cfg.py | 7 +- cli/app/site/export.py | 23 +- cli/cli.py | 7 + cli/commands/site/export.py | 6 +- env-sample | 1 + frontend/app/common/app.css | 14 + frontend/app/utils/index.js | 3 +- .../app/views/audio/components/audio.select.js | 11 +- frontend/app/views/graph/components/page.form.js | 10 +- frontend/app/views/graph/graph.actions.js | 11 +- frontend/app/views/graph/graph.reducer.js | 14 + frontend/app/views/page/components/page.header.js | 18 +- frontend/app/views/page/components/tile.form.js | 41 +- frontend/app/views/page/components/tile.handle.js | 1 - frontend/site/audio/audio.player.js | 43 + frontend/site/index.js | 2 + frontend/site/site.css | 20 + frontend/site/site/site.actions.js | 4 + frontend/site/site/site.reducer.js | 7 + frontend/site/store.js | 3 +- frontend/site/types.js | 4 +- frontend/site/viewer/viewer.container.js | 60 +- package-lock.json | 11491 ------------------- package.json | 4 +- webpack.config.dev.js | 45 +- webpack.config.prod.js | 55 +- yarn.lock | 5 + 30 files changed, 391 insertions(+), 11580 deletions(-) create mode 100644 cli/app/server/demo.py create mode 100644 frontend/site/audio/audio.player.js create mode 100644 frontend/site/site.css delete mode 100644 package-lock.json (limited to 'cli') diff --git a/cli/app/controllers/graph_controller.py b/cli/app/controllers/graph_controller.py index 7efda73..fcca50a 100644 --- a/cli/app/controllers/graph_controller.py +++ b/cli/app/controllers/graph_controller.py @@ -7,6 +7,7 @@ from app.sql.models.graph import Graph, GraphForm from app.sql.models.page import Page from app.sql.models.tile import Tile from app.controllers.crud_controller import CrudView +from app.site.export import export_site class GraphView(CrudView): model = Graph @@ -20,7 +21,7 @@ class GraphView(CrudView): @route('/name/', methods=['GET']) def get_name(self, graph_path: str): """ - Fetch a single {model}. + Fetch a single graph. """ session = Session() item = session.query(self.model).filter(self.model.path == graph_path).first() @@ -36,3 +37,11 @@ class GraphView(CrudView): } session.close() return jsonify(result) + + @route('/export/', methods=['GET']) + def export(self, graph_path: str): + export_site(opt_graph_path=graph_path) + result = { + 'status': 'ok', + } + return jsonify(result) diff --git a/cli/app/server/demo.py b/cli/app/server/demo.py new file mode 100644 index 0000000..847f95b --- /dev/null +++ b/cli/app/server/demo.py @@ -0,0 +1,48 @@ +import os +import logging +import logging.handlers + +logger = logging.getLogger("") +logger.setLevel(logging.DEBUG) +handler = logging.handlers.RotatingFileHandler("flask.log", + maxBytes=3000000, backupCount=2) +formatter = logging.Formatter( + '[%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s') +handler.setFormatter(formatter) +logger.addHandler(handler) +logging.getLogger().addHandler(logging.StreamHandler()) + +from flask import Flask, send_from_directory, request + +from app.settings import app_cfg + +def create_demo_app(script_info=None): + """ + functional pattern for creating the flask app + """ + logging.debug("Starting Swimmer demo server...") + app = Flask(__name__, static_folder=app_cfg.DIR_EXPORTS, static_url_path='/') + app.config['SERVER_NAME'] = app_cfg.DEMO_SERVER_NAME + app.url_map.strict_slashes = False + + @app.errorhandler(404) + def not_found(error): + path, fn = os.path.split(request.path) + path = path[1:] + dir_path = os.path.join(app_cfg.DIR_EXPORTS, path) + if os.path.isfile(os.path.join(dir_path, fn)): + return send_from_directory(dir_path, fn) + if os.path.isfile(os.path.join(dir_path, fn, 'index.html')): + return send_from_directory(os.path.join(dir_path, fn), 'index.html') + return "404", 404 + + @app.route('/') + def serve_index(): + return "Swimmer demo", 200 + + @app.route('/favicon.ico') + def favicon(): + return send_from_directory(os.path.join(app_cfg.DIR_STATIC, 'img'), + 'favicon.ico', mimetype='image/vnd.microsoft.icon') + + return app diff --git a/cli/app/server/web.py b/cli/app/server/web.py index 1a3b064..5eb172c 100644 --- a/cli/app/server/web.py +++ b/cli/app/server/web.py @@ -52,7 +52,7 @@ def create_app(script_info=None): @app.route('/favicon.ico') def favicon(): return send_from_directory(os.path.join(app.root_path, 'static/img/'), - 'favicon.ico',mimetype='image/vnd.microsoft.icon') + 'favicon.ico', mimetype='image/vnd.microsoft.icon') @app.shell_context_processor def shell_context(): diff --git a/cli/app/settings/app_cfg.py b/cli/app/settings/app_cfg.py index af6cf89..4aa4bee 100644 --- a/cli/app/settings/app_cfg.py +++ b/cli/app/settings/app_cfg.py @@ -15,9 +15,10 @@ codecs.register(lambda name: codecs.lookup('utf8') if name == 'utf8mb4' else Non LOG = logging.getLogger('swimmer') # ----------------------------------------------------------------------------- -# .env config for keys +# .env config # ----------------------------------------------------------------------------- # Project directory + SELF_CWD = os.path.dirname(os.path.realpath(__file__)) # this file DIR_PROJECT_ROOT = str(Path(SELF_CWD).parent.parent.parent) @@ -31,14 +32,13 @@ load_dotenv(dotenv_path=fp_env) # ----------------------------------------------------------------------------- CLICK_GROUPS = { - # 'process': 'commands/process', 'site': 'commands/site', 'admin': 'commands/admin', 'db': '', 'flask': '', + 'demo': '', } - # ----------------------------------------------------------------------------- # File I/O # ----------------------------------------------------------------------------- @@ -86,6 +86,7 @@ except Exception as e: # ----------------------------------------------------------------------------- SERVER_NAME = os.getenv('SERVER_NAME') or '0.0.0.0:5000' +DEMO_SERVER_NAME = os.getenv('DEMO_SERVER_NAME') or '0.0.0.0:3000' HTTP_EXTERNAL_HOST = os.getenv('HTTP_EXTERNAL_HOST') or f"http://{SERVER_NAME}" # ----------------------------------------------------------------------------- diff --git a/cli/app/site/export.py b/cli/app/site/export.py index d513286..c301a60 100644 --- a/cli/app/site/export.py +++ b/cli/app/site/export.py @@ -8,7 +8,7 @@ import os from app.sql.common import db, Session, Graph, Page, Tile from distutils.dir_util import copy_tree -def export_site(ctx, opt_graph_path, opt_output_dir): +def export_site(opt_graph_path, opt_output_dir=app_cfg.DIR_EXPORTS, opt_build_js=False): """Export a graph""" # ------------------------------------------------ @@ -42,6 +42,7 @@ def export_site(ctx, opt_graph_path, opt_output_dir): home_page = site_data['graph']['home_page'] if home_page is None: print("Homepage not set! Shift-click a page on the graph to make it the homepage.") + session.close() return write_text(f'', join(graph_dir, 'index.html')) @@ -55,8 +56,10 @@ def export_site(ctx, opt_graph_path, opt_output_dir): print(f'/{page_path}') write_index(graph, page, index_html, join(graph_dir, page.path, 'index.html')) - build_javascript(graph_dir) + if opt_build_js or not os.path.exists(f"{graph_dir}/bundle.js"): + build_javascript(graph_dir) + session.close() print("Site export complete!") print(f"Graph exported to: {graph_dir}") @@ -86,22 +89,34 @@ def sanitize_graph(graph): page_path_lookup[page['id']] = page_path for page in graph['pages']: sanitize_page(page) - if page['id'] == 12: - print(page) for tile in page['tiles']: if tile['target_page_id']: if tile['target_page_id'] == -1: tile['href'] = tile['settings']['external_link_url'] elif tile['target_page_id'] > 0: tile['href'] = page_path_lookup[tile['target_page_id']] + if 'url' in tile['settings'] and tile['settings']['url'].startswith('/static'): + tile['settings']['url'] = '/' + graph['path'] + tile['settings']['url'] sanitize_tile(tile) page_path = page_path_lookup[page['id']] page_lookup[page_path] = page + for upload in graph['uploads']: + sanitize_upload(upload) + if upload['url'].startswith('/static'): + upload['url'] = '/' + graph['path'] + upload['url'] # print(page_lookup['/asdf/testttt']) graph['pages'] = page_lookup graph['home_page'] = page_path_lookup[graph['home_page_id']] return graph +def sanitize_upload(data): + if 'created_at' in data: + del data['created_at'] + if 'username' in data: + del data['username'] + if 'graph_id' in data: + del data['graph_id'] + def sanitize_page(data): if 'created_at' in data: del data['created_at'] diff --git a/cli/cli.py b/cli/cli.py index 2158398..3534c43 100755 --- a/cli/cli.py +++ b/cli/cli.py @@ -29,6 +29,13 @@ if __name__ == '__main__': cli = FlaskGroup(create_app=create_app) + elif args.group == 'demo': + + from flask.cli import FlaskGroup + from app.server.demo import create_demo_app + + cli = FlaskGroup(create_app=create_demo_app) + elif args.group == 'db': import re diff --git a/cli/commands/site/export.py b/cli/commands/site/export.py index 68773e9..78e7228 100644 --- a/cli/commands/site/export.py +++ b/cli/commands/site/export.py @@ -8,8 +8,10 @@ from app.site.export import export_site help='Graph name') @click.option('-o', '--output', 'opt_output_dir', required=True, default=app_cfg.DIR_EXPORTS, help='Output dir') +@click.option('-j', '--js/--no-js', 'opt_js', required=False, default=False, + help='Whether to rebuild the Javascript bundle') @click.pass_context -def cli(ctx, opt_graph_path, opt_output_dir): +def cli(ctx, opt_graph_path, opt_output_dir, opt_build_js): """Export a graph""" - export_site(opt_graph_path, opt_output_dir) \ No newline at end of file + export_site(opt_graph_path, opt_output_dir, opt_build_js) \ No newline at end of file diff --git a/env-sample b/env-sample index 83c36e4..6df55d5 100644 --- a/env-sample +++ b/env-sample @@ -1,6 +1,7 @@ FLASK_RUN_PORT=7500 SERVER_NAME=swim.neural.garden HTTP_EXTERNAL_HOST=https://swim.neural.garden +EXPORT_HOST=http://3.k:3000 USE_SQLITE=False diff --git a/frontend/app/common/app.css b/frontend/app/common/app.css index 2e9dc4e..486e5fa 100644 --- a/frontend/app/common/app.css +++ b/frontend/app/common/app.css @@ -116,6 +116,20 @@ header > div > button:hover { border-color: #fff; color: #fff; } + +header .building { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + margin-left: 1rem; + color: #888; +} +header .building .loader { + transform: scale(0.75); + margin-right: 0.5rem; +} + header .vcat-btn { font-size: 0.875rem; padding-left: 0.5rem; diff --git a/frontend/app/utils/index.js b/frontend/app/utils/index.js index 1114d65..6e5a909 100644 --- a/frontend/app/utils/index.js +++ b/frontend/app/utils/index.js @@ -50,7 +50,8 @@ export const pad = (n, m) => { } export const courtesyS = (n, s) => n + ' ' + (n === 1 ? s : s + 's') - +export const capitalize = s => s.split(' ').map(capitalizeWord).join(' ') +export const capitalizeWord = s => s.substr(0, 1).toUpperCase() + s.substr(1) export const padSeconds = n => n < 10 ? '0' + n : n export const timestamp = (n = 0, fps = 25) => { diff --git a/frontend/app/views/audio/components/audio.select.js b/frontend/app/views/audio/components/audio.select.js index 73142f0..cf1dfb2 100644 --- a/frontend/app/views/audio/components/audio.select.js +++ b/frontend/app/views/audio/components/audio.select.js @@ -2,6 +2,7 @@ import React, { Component } from 'react' import { connect } from 'react-redux' import { Select } from 'app/common' +import { unslugify } from 'app/utils' const NO_AUDIO = 0 const AUDIO_TOP_OPTIONS = [ @@ -16,14 +17,14 @@ class AudioSelect extends Component { constructor(props) { super(props) - this.handleSelect = this.handleSelect.bind(this) + this.handleChange = this.handleChange.bind(this) } componentDidMount(){ const { uploads } = this.props.graph.show.res const audioUploads = uploads .filter(upload => upload.tag === 'audio') - .map(page => ({ name: upload.id, label: page.path })) + .map(upload => ({ name: upload.id, label: unslugify(upload.fn) })) let audioList = [ ...AUDIO_TOP_OPTIONS, ...audioUploads, @@ -33,6 +34,10 @@ class AudioSelect extends Component { }) } + handleChange(name, value) { + this.props.onChange(name, parseInt(value)) + } + render() { return ( - - } - - - ) - } - - renderVideoForm() { - // const { isNew } = this.props - const { temporaryTile } = this.props - const { errorFields } = this.state - // console.log(temporaryTile.settings) - return ( -
-
- -
-
-