Compare commits

..

No commits in common. "9920a0a596b39ed383d2f6d663fc8593571838d2" and "14ca22f3e9b8f6d6aa773a5a29573586549f5ffd" have entirely different histories.

11 changed files with 41 additions and 358 deletions

View File

@ -1,36 +0,0 @@
import datetime
from bson.objectid import ObjectId
from util import database, util
def list_for_user(user):
db = database.get_db()
snippets = db.snippets.find({'user': user['_id']}).sort('createdAt', -1)
return {'snippets': list(snippets)}
def create(user, data):
if not data: raise util.errors.BadRequest('Invalid request')
name = data.get('name', '')
snippet_type = data.get('type', '')
if len(name) < 3: raise util.errors.BadRequest('A longer name is required')
if snippet_type not in ['warp', 'weft']:
raise util.errors.BadRequest('Invalid snippet type')
db = database.get_db()
snippet = {
'name': name,
'user': user['_id'],
'createdAt': datetime.datetime.utcnow(),
'type': snippet_type,
'threading': data.get('threading', []),
'treadling': data.get('treadling', []),
}
result = db.snippets.insert_one(snippet)
snippet['_id'] = result.inserted_id
return snippet
def delete(user, id):
db = database.get_db()
snippet = db.snippets.find_one({'_id': ObjectId(id), 'user': user['_id']})
if not snippet:
raise util.errors.NotFound('Snippet not found')
db.snippets.remove({'_id': snippet['_id']})
return {'deletedSnippet': snippet['_id'] }

View File

@ -6,7 +6,7 @@ import werkzeug
import sentry_sdk
from sentry_sdk.integrations.flask import FlaskIntegration
from util import util
from api import accounts, users, projects, objects, snippets, uploads, groups, search, invitations, root, activitypub
from api import accounts, users, projects, objects, uploads, groups, search, invitations, root, activitypub
app = Flask(__name__)
CORS(app)
@ -188,19 +188,6 @@ def object_comments(id):
def object_comment(id, comment_id):
return util.jsonify(objects.delete_comment(util.get_user(), id, comment_id))
# SNIPPETS
@app.route('/snippets', methods=['POST', 'GET'])
def snippets_route():
if request.method == 'POST':
return util.jsonify(snippets.create(util.get_user(), request.json))
if request.method == 'GET':
return util.jsonify(snippets.list_for_user(util.get_user()))
@app.route('/snippets/<id>', methods=['DELETE'])
def snippet_route(id):
if request.method == 'DELETE':
return util.jsonify(snippets.delete(util.get_user(), id))
# GROUPS
@app.route('/groups', methods=['POST', 'GET'])
@ -342,4 +329,4 @@ def ap_user_outbox(username):
resp_data = activitypub.outbox(username, page, min_id, max_id)
resp = Response(json.dumps(resp_data))
resp.headers['Content-Type'] = 'application/activity+json'
return resp
return resp

View File

@ -8,8 +8,6 @@ export default {
DELETE_OBJECT: 'DELETE_OBJECT',
SELECT_OBJECT: 'SELECT_OBJECT',
UPDATE_EDITOR: 'UPDATE_EDITOR',
RECEIVE_SNIPPET: 'RECEIVE_SNIPPET',
DELETE_SNIPPET: 'DELETE_SNIPPET',
RECEIVE_COMMENT: 'RECEIVE_COMMENT',
DELETE_COMMENT: 'DELETE_COMMENT',
@ -47,14 +45,6 @@ export default {
return { type: this.UPDATE_EDITOR, editor };
},
receiveSnippet(snippet) {
return { type: this.RECEIVE_SNIPPET, snippet };
},
deleteSnippet(snippetId) {
return { type: this.DELETE_SNIPPET, snippetId };
},
receiveComment(comment) {
return { type: this.RECEIVE_COMMENT, comment };
},

View File

@ -5,7 +5,6 @@ import { accounts } from './accounts';
import { users } from './users';
import { projects } from './projects';
import { objects } from './objects';
import { snippets } from './snippets';
import { uploads } from './uploads';
import { groups } from './groups';
import { search } from './search';
@ -54,7 +53,7 @@ export const api = {
api.req(method, path, data, success, fail, true, options);
},
auth, accounts, users, projects, objects, snippets, uploads, groups, search, invitations, root
auth, accounts, users, projects, objects, uploads, groups, search, invitations, root
};
export default api;

View File

@ -1,13 +0,0 @@
import api from '.';
export const snippets = {
create(data, success, fail) {
api.authenticatedRequest('POST', '/snippets', data, success, fail);
},
listForUser(success, fail) {
api.authenticatedRequest('GET', `/snippets`, null, success, fail);
},
delete(snippetId, success, fail) {
api.authenticatedRequest('DELETE', `/snippets/${snippetId}`, null, success, fail);
},
};

View File

@ -1,142 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Icon, Divider, Header, Button, Modal, ModalHeader, ModalContent, ModalDescription, ModalActions, Input, Card, Checkbox } from 'semantic-ui-react';
import styled from 'styled-components';
import { useDispatch, useSelector } from 'react-redux';
import api from '../../api';
import actions from '../../actions';
import Warp from '../main/projects/objects/Warp';
import Weft from '../main/projects/objects/Weft';
import { PreviewContainer } from './SnippetSaver';
export default function SnippetChooser({ type, isOpen, onComplete }) {
const [count, setCount] = useState(1);
const [includeColours, setIncludeColours] = useState(false);
const [selectedSnippet, setSelectedSnippet] = useState(null);
const dispatch = useDispatch();
const { snippets } = useSelector(state => {
return { snippets: state.objects.snippets };
});
useEffect(() => {
api.snippets.listForUser(data => {
data?.snippets?.forEach(s => dispatch(actions.objects.receiveSnippet(s)));
});
}, []);
const deleteSnippet = (id) => {
if (!window.confirm('Are you sure you want to delete this snippet?')) return;
api.snippets.delete(id, () => {
dispatch(actions.objects.deleteSnippet(id));
setSelectedSnippet(null);
});
}
const insertSnippet = () => {
if (!count) return onComplete();
const snippet = selectedSnippet ?
{ ...snippets.find(s => s._id === selectedSnippet) }
:
{
type,
threading: type === 'warp' ? [{shaft: 0}] : null,
treadling: type === 'weft' ? [{treadle: 0}] : null,
};
if (snippet) {
const key = type === 'warp' ? 'threading' : 'treadling';
const baseArray = [...snippet[key]];
const newArray = [];
for (let i = 0; i < count; i++) {
baseArray.forEach(t => {
newArray.push({ ...t, colour: includeColours ? t.colour : undefined });
});
}
snippet[key] = newArray;
}
onComplete(snippet);
}
const typeSnippets = snippets.filter(s => s.type === type);
return (
<Modal
onClose={() => onComplete()}
open={isOpen}
>
<ModalHeader>Insert into the {type}</ModalHeader>
<ModalContent>
<ModalDescription>
<Input
label={{ basic: true, content: 'Number of repeats to insert:' }}
labelPosition='left'
placeholder='Count'
value={count}
onChange={(e, { value }) => setCount(parseInt(value || 0) || 0)}
/>
<Divider hidden />
<Header size='small'>Choose a blank thread or a snippet</Header>
<Card.Group stackable doubling itemsPerRow={4}>
<Card
raised={!selectedSnippet}
color={!selectedSnippet ? 'blue' : null}
onClick={() => setSelectedSnippet(null)}
>
<Card.Content textAlign='center'>
{!selectedSnippet && <Icon name='check circle' size='sm' />}
<Header size='sm'>Blank thread</Header>
</Card.Content>
</Card>
{!typeSnippets.length ? <Card>
<Card.Content textAlign='center'>
You don't have any {type} snippets yet. Create one by choosing threads in your pattern using the "select" tool.
</Card.Content>
</Card> : null}
{typeSnippets.map(s => {
const shafts = type === 'warp' ? Math.max(...s.threading?.map(t => t.shaft)) : 0;
const treadles = type === 'weft' ? Math.max(...s.treadling?.map(t => t.treadle)) : 0;
return (<Card key={s._id}
raised={selectedSnippet === s._id}
color={selectedSnippet === s._id ? 'blue' : null}
onClick={() => setSelectedSnippet(s._id)}
>
<Card.Content textAlign='center'>
{selectedSnippet === s._id && <Icon name='check circle' size='sm' />}
<Header size='sm' style={{margin: 0}}>
{s.name}
</Header>
<div style={{overflow: 'scroll'}}>
<PreviewContainer type={type} shafts={shafts} treadles={treadles} threading={s.threading} treadling={s.treadling}>
{type === 'warp' &&
<Warp baseSize={10} warp={{threading: s.threading, shafts}} weft={{treadles, treadling: []}}/>
}
{type === 'weft' &&
<Weft baseSize={10} warp={{shafts, threading: []}} weft={{treadling: s.treadling, treadles}}/>
}
</PreviewContainer>
</div>
</Card.Content>
<Card.Content extra>
<a href='#' onClick={() => deleteSnippet(s._id)}>Delete snippet</a>
</Card.Content>
</Card>);
})}
</Card.Group>
{selectedSnippet && <>
<Divider hidden />
<Checkbox
label='Insert with snippet colours'
onChange={(e, data) => setIncludeColours(data.checked)}
checked={includeColours}
/>
</>}
</ModalDescription>
</ModalContent>
<ModalActions>
<Button color='black' onClick={() => onComplete()}>Cancel</Button>
<Button onClick={insertSnippet} positive>Insert selected thread or snippet</Button>
</ModalActions>
</Modal>
);
}

View File

@ -1,78 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Button, Card, Header, Modal, ModalHeader, ModalContent, ModalDescription, ModalActions, Input } from 'semantic-ui-react';
import styled from 'styled-components';
import api from '../../api';
import Warp from '../main/projects/objects/Warp';
import Weft from '../main/projects/objects/Weft';
export const PreviewContainer = styled.div`
display: inline-block;
margin-top: 10px;
position: relative;
height: ${p =>
(p.type === 'warp' ? p.shafts * 10 :
p.type === 'weft' ? p.treadling?.length * 10 : 0) + 20}px;
}px;
width: ${p =>
(p.type === 'warp' ? p.threading?.length * 10 :
p.type === 'weft' ? p.treadles * 10 : 0) + 20}px;
}px;
overflow-y: scroll;
`;
export default function SnippetSaver({ type, threading, treadling, isOpen, onComplete }) {
const [name, setName] = useState('');
useEffect(() => {
setName('Snippet made on ' + new Date().toLocaleString());
}, []);
const save = () => {
api.snippets.create({ name, type, threading, treadling }, newSnippet => {
onComplete(newSnippet);
});
}
let shafts = 0;
threading?.forEach(thread => {
if (thread.shaft > shafts) shafts = thread.shaft;
});
let treadles = 0;
treadling?.forEach(thread => {
if (thread.treadle > treadles) treadles = thread.treadle;
});
return (
<Modal
onClose={() => onComplete()}
open={isOpen}
>
<ModalHeader>Save a {type} snippet</ModalHeader>
<ModalContent>
<ModalDescription>
<p>Snippets are partial warps or wefts that you can re-use later in this and other drafts.</p>
<p><strong>Give your snippet a name:</strong></p>
<Input placeholder='Type a name...' value={name} onChange={e => setName(e.target.value)} fluid autoFocus />
<Card raised>
<Card.Content textAlign='center'>
<Header size='sm'>Preview</Header>
<div style={{overflow: 'scroll'}}>
<PreviewContainer type={type} threading={threading} treadling={treadling} shafts={shafts} treadles={treadles}>
{type === 'warp' &&
<Warp baseSize={10} warp={{threading, shafts}} weft={{treadles: 0, treadling: []}}/>
}
{type === 'weft' &&
<Weft baseSize={10} warp={{shafts: 0, threading: []}} weft={{treadling, treadles}}/>
}
</PreviewContainer>
</div>
</Card.Content>
</Card>
</ModalDescription>
</ModalContent>
<ModalActions>
<Button color='black' onClick={() => onComplete()}>Cancel</Button>
<Button onClick={save} positive>Save snippet</Button>
</ModalActions>
</Modal>
);
}

View File

@ -10,8 +10,6 @@ import Slider from 'rc-slider';
import styled from 'styled-components';
import HelpLink from '../../../includes/HelpLink';
import Tour, { ReRunTour } from '../../../includes/Tour';
import SnippetSaver from '../../../includes/SnippetSaver';
import SnippetChooser from '../../../includes/SnippetChooser';
import 'rc-slider/assets/index.css';
@ -57,7 +55,6 @@ function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updateP
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [newColour, setNewColour] = useState('#22194D');
const [selectedThreadCount, setSelectedThreadCount] = useState(0);
const [creatingSnippet, setCreatingSnippet] = useState(null);
const navigate = useNavigate();
const dispatch = useDispatch();
const { objectId, username, projectPath } = useParams();
@ -137,33 +134,6 @@ function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updateP
newWeft.treadling = newWeft?.treadling?.map(t => ({ ...t, isSelected: undefined }));
updatePattern({ warp: newWarp, weft: newWeft });
}
const saveSnippet = () => {
const selectedWarp = warp?.threading?.filter(t => t.isSelected)?.map(t => ({ shaft: t.shaft, colour: t.colour }));
const selectedWeft = weft?.treadling?.filter(t => t.isSelected)?.map(t => ({ treadle: t.treadle, colour: t.colour}));
if (selectedWarp?.length) setCreatingSnippet({ type: 'warp', threading: selectedWarp, treadling: null });
else setCreatingSnippet({ type: 'weft', threading: null, treadling: selectedWeft });
}
const onSaveSnippet = (snippet) => {
setCreatingSnippet(null);
dispatch(actions.objects.receiveSnippet(snippet));
if (snippet) {
toast('📌 Snippet saved');
}
}
const onInsertSnippet = (snippet) => {
if (snippet) {
const newWarp = Object.assign({}, pattern.warp);
const newWeft = Object.assign({}, pattern.weft);
if (snippet.type === 'warp') {
newWarp.threading.splice(editor.insertPoint, 0, ...snippet.threading);
}
else {
newWeft.treadling.splice(editor.insertPoint, 0, ...snippet.treadling);
}
updatePattern({ warp: newWarp, weft: newWeft });
}
dispatch(actions.objects.updateEditor( { insertType: null, inseertPoint: null }));
}
const onZoomChange = zoom => updatePattern({ baseSize: zoom || 10 });
@ -238,7 +208,6 @@ function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updateP
<div>
<Header>{selectedThreadCount} threads selected</Header>
<Button size='small' basic onClick={deselectThreads}>De-select all</Button>
<Button size='small' color='teal' onClick={saveSnippet}>Save selection as snippet</Button>
<Button size='small' color='orange' onClick={deleteSelectedThreads}>Delete threads</Button>
</div>
:
@ -270,7 +239,7 @@ function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updateP
<Button.Group size="tiny">
<Button className='joyride-pan' data-tooltip="Pan (drag to move) pattern" color={editor.tool === 'pan' && 'blue'} size="tiny" icon onClick={() => enableTool('pan')}><Icon name="move" /></Button>
<Button data-tooltip="Select threads" color={editor.tool === 'select' && 'blue'} size="tiny" icon onClick={() => enableTool('select')}><Icon name="i cursor" /></Button>
<Button data-tooltip="Insert threads or snippets" color={editor.tool === 'insert' && 'blue'} size="tiny" icon onClick={() => enableTool('insert')}><Icon name="plus" /></Button>
<Button data-tooltip="Insert threads" color={editor.tool === 'insert' && 'blue'} size="tiny" icon onClick={() => enableTool('insert')}><Icon name="plus" /></Button>
<Button className='joyride-colour' data-tooltip="Apply thread colour" color={editor.tool === 'colour' && 'blue'} size="tiny" icon onClick={() => enableTool('colour')}><Icon name="paint brush" /></Button>
<Button data-tooltip="Erase threads" color={editor.tool === 'eraser' && 'blue'} size="tiny" icon onClick={() => enableTool('eraser')}><Icon name="eraser" /></Button>
<Button className='joyride-straight' data-tooltip="Straight draw" color={editor.tool === 'straight' && 'blue'} size="tiny" icon onClick={() => enableTool('straight')}>/ /</Button>
@ -285,7 +254,7 @@ function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updateP
</div>
<Button.Group size="tiny">
<Popup hoverable on="click"
trigger={<Button size="tiny" icon="tint" style={{marginLeft: 5, color: utils.rgb(editor.colour)}} />}
trigger={<Button style={{marginLeft: 5}} size="tiny" icon="tint" style={{color: utils.rgb(editor.colour)}} />}
content={<div style={{width: 150}}>
{pattern.colours && pattern.colours.map(colour =>
<ColourSquare key={colour} colour={utils.rgb(colour)} onClick={() => setColour(colour)} />
@ -363,6 +332,26 @@ function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updateP
onConfirm={deleteObject}
/>
{/*}
<Dropdown icon={null} direction='left' disabled={unsaved}
trigger={<Button color='blue' size='tiny' icon='download' style={{marginLeft: 5}} />}
>
<Dropdown.Menu>
{object.previewUrl &&
<Dropdown.Item onClick={e => utils.downloadDrawdownImage(object)} content='Download drawdown as an image' icon='file outline' />
}
{(utils.canEditProject(user, project) || project.openSource) &&
<>
{object.patternImage &&
<Dropdown.Item icon='file outline' content='Download complete pattern as an image' onClick={e => utils.downloadPatternImage(object)}/>
}
<Dropdown.Divider />
<Dropdown.Item onClick={e => utils.downloadWif(object)} content="Download pattern in WIF format" icon="text file" />
</>
}
</Dropdown.Menu>
</Dropdown>*/}
<Dropdown style={{marginLeft: 5}} icon={null} direction='left'
trigger={<Button basic size='tiny' icon='help' />}
>
@ -381,10 +370,6 @@ function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updateP
</div>
</div>
</Segment>
{creatingSnippet &&
<SnippetSaver type={creatingSnippet.type} threading={creatingSnippet.threading} treadling={creatingSnippet.treadling} isOpen={!!creatingSnippet} onComplete={onSaveSnippet} />
}
<SnippetChooser type={editor?.insertType} isOpen={!!editor?.insertType} onComplete={onInsertSnippet} />
</div>
);
}

View File

@ -1,8 +1,7 @@
import React, { useEffect, useState, useRef } from 'react';
import styled from 'styled-components';
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import utils from '../../../../utils/utils.js';
import actions from '../../../../actions/index.js';
const WarpContainer = styled.div`
top:0px;
@ -51,7 +50,6 @@ function Warp({ baseSize, cellStyle, warp, weft, updatePattern }) {
const { tool, colour } = editor;
const warpRef = useRef(null);
const colourwayRef = useRef(null);
const dispatch = useDispatch();
useEffect(() => paintDrawdown());
useEffect(() => {
@ -220,7 +218,13 @@ function Warp({ baseSize, cellStyle, warp, weft, updatePattern }) {
updatePattern({ warp: newWarp });
}
if (editor.tool === 'insert') {
dispatch(actions.objects.updateEditor({ insertType: 'warp', insertPoint: thread }));
const number = parseInt(prompt('Enter a number of threads to insert before this point.'));
if (number && number > 0) {
const newThreads = [...new Array(number)].map(() => ({ shaft: 0 }));
const newWarp = Object.assign({}, warp);
newWarp.threading?.splice(thread, 0, ...newThreads);
updatePattern({ warp: newWarp });
}
}
};

View File

@ -1,8 +1,7 @@
import React, { useEffect, useState, useRef } from 'react';
import styled from 'styled-components';
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import utils from '../../../../utils/utils.js';
import actions from '../../../../actions';
const WeftContainer = styled.div`
position: absolute;
@ -51,7 +50,6 @@ function Weft({ cellStyle, warp, weft, baseSize, updatePattern }) {
const { tool, colour } = editor;
const weftRef = useRef(null);
const colourwayRef = useRef(null);
const dispatch = useDispatch();
useEffect(() => paintDrawdown());
useEffect(() => {
@ -222,7 +220,13 @@ function Weft({ cellStyle, warp, weft, baseSize, updatePattern }) {
updatePattern({ weft: newWeft });
}
if (editor.tool === 'insert') {
dispatch(actions.objects.updateEditor({ insertType: 'weft', insertPoint: thread - 1 }));
const number = parseInt(prompt('Enter a number of threads to insert above this point.'));
if (number && number > 0) {
const newThreads = [...new Array(number)].map(() => ({ treadle: 0 }));
const newWeft = Object.assign({}, weft);
newWeft.treadling?.splice(thread - 1, 0, ...newThreads);
updatePattern({ weft: newWeft });
}
}
};

View File

@ -8,7 +8,6 @@ const initialState = {
comments: [],
selected: null,
editor: { tool: 'straight', colour: null, view: 'interlacement', autoExtend: true },
snippets: [],
};
function objects(state = initialState, action) {
@ -77,22 +76,6 @@ function objects(state = initialState, action) {
case actions.objects.UPDATE_EDITOR:
const editor = Object.assign({}, state.editor, action.editor);
return Object.assign({}, state, { editor });
case actions.objects.RECEIVE_SNIPPET: {
const snippets = [];
let found = false;
state.snippets.forEach((snippet) => {
if (snippet._id === action.snippet._id) {
snippet = Object.assign({}, action.snippet);
found = true;
}
snippets.push(snippet);
});
if (!found) snippets.splice(0, 0, action.snippet);
return Object.assign({}, state, { loading: false, snippets });
}
case actions.objects.DELETE_SNIPPET: {
return Object.assign({}, state, { snippets: state.snippets.filter(e => e._id !== action.snippetId) });
}
case actions.objects.RECEIVE_COMMENT: {
let found = false;