├── .meteor
├── .gitignore
├── release
├── platforms
├── .id
├── .finished-upgraders
├── packages
└── versions
├── .gitignore
├── screenshot.png
├── google_cloud_oauth.png
├── google_cloud_search.png
├── google_oauth_create.png
├── client
├── focus-plugin.css
├── main.html
├── main.jsx
├── alignment-plugin.css
├── image-plugin.css
├── toolbar-plugin.css
├── Draft.css
└── main.css
├── imports
├── user.js
├── startup
│ ├── server
│ │ ├── smtp.js
│ │ ├── s3.js
│ │ └── google-oauth.js
│ ├── accounts.js
│ └── client
│ │ └── routes.jsx
├── api
│ ├── users.js
│ ├── client
│ │ └── uploadEntryImage.js
│ ├── server
│ │ └── notifications.js
│ └── entries
│ │ ├── entries.js
│ │ └── methods.js
├── materializeEntryUsers.js
└── ui
│ ├── components
│ ├── UserButton.jsx
│ ├── EntryTextField.jsx
│ ├── ToggleList.jsx
│ ├── EntryImage.jsx
│ ├── SourceLink.jsx
│ ├── SearchField.jsx
│ ├── EntryList.jsx
│ ├── EntryCell.jsx
│ ├── TagEditor.jsx
│ ├── DescriptionEditor.jsx
│ └── Entry.jsx
│ ├── App.jsx
│ ├── EntryPage.jsx
│ └── Home.jsx
├── public
└── images
│ ├── check_active.png
│ ├── check_inactive.png
│ ├── heart_active.png
│ └── heart_inactive.png
├── settings.template.json
├── server
└── main.js
├── package.json
└── Readme.md
/.meteor/.gitignore:
--------------------------------------------------------------------------------
1 | local
2 |
--------------------------------------------------------------------------------
/.meteor/release:
--------------------------------------------------------------------------------
1 | METEOR@1.4.0.1
2 |
--------------------------------------------------------------------------------
/.meteor/platforms:
--------------------------------------------------------------------------------
1 | server
2 | browser
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | settings.json
3 | settings-debug.json
4 |
--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Khan/hivemind/HEAD/screenshot.png
--------------------------------------------------------------------------------
/google_cloud_oauth.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Khan/hivemind/HEAD/google_cloud_oauth.png
--------------------------------------------------------------------------------
/google_cloud_search.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Khan/hivemind/HEAD/google_cloud_search.png
--------------------------------------------------------------------------------
/google_oauth_create.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Khan/hivemind/HEAD/google_oauth_create.png
--------------------------------------------------------------------------------
/client/focus-plugin.css:
--------------------------------------------------------------------------------
1 | .draftJsEmojiPlugin__focused__3Mksn {
2 | outline: 3px solid black;
3 | }
4 |
--------------------------------------------------------------------------------
/imports/user.js:
--------------------------------------------------------------------------------
1 | export function getUserFirstName(user) {
2 | return user.profile.name.split(" ")[0];
3 | }
4 |
--------------------------------------------------------------------------------
/public/images/check_active.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Khan/hivemind/HEAD/public/images/check_active.png
--------------------------------------------------------------------------------
/public/images/check_inactive.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Khan/hivemind/HEAD/public/images/check_inactive.png
--------------------------------------------------------------------------------
/public/images/heart_active.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Khan/hivemind/HEAD/public/images/heart_active.png
--------------------------------------------------------------------------------
/public/images/heart_inactive.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Khan/hivemind/HEAD/public/images/heart_inactive.png
--------------------------------------------------------------------------------
/imports/startup/server/smtp.js:
--------------------------------------------------------------------------------
1 | export default function () {
2 | process.env.MAIL_URL = Meteor.settings.notificationEmails.mailURL;
3 | }
4 |
--------------------------------------------------------------------------------
/client/main.html:
--------------------------------------------------------------------------------
1 |
2 | {
24 | this.setState({
25 | value: this.props.value,
26 | hasFocus: true
27 | })
28 | }}
29 | onBlur={(event) => {
30 | this.onChange(event)
31 | this.setState({hasFocus: false})
32 | }}
33 | value={this.state.hasFocus ? this.state.value : this.props.value}
34 | />;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/imports/ui/components/ToggleList.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { getUserFirstName } from '../../user.js';
3 |
4 | export default class ToggleList extends React.Component {
5 | render() {
6 | const {users, currentUser, disabled} = this.props;
7 | const isActive = (users && currentUser) ? users.find((user) => user._id === currentUser._id) : false;
8 |
9 | let names = No one yet ;
10 | if (users && users.length > 0) {
11 | names = users.map((user) => {
12 | return {getUserFirstName(user)} ;
13 | });
14 | }
15 |
16 | const iconImage = ;
17 | const icon = (currentUser && !disabled) ? (
18 | {
19 | this.props.onChange(!isActive);
20 | event.preventDefault();
21 | }}>{iconImage}
22 | ) : iconImage;
23 |
24 | return (
25 | {icon}
26 | {names}
27 |
);
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/imports/ui/components/EntryImage.jsx:
--------------------------------------------------------------------------------
1 | import Dropzone from 'react-dropzone';
2 | import React from 'react';
3 |
4 | export default class EntryImage extends React.Component {
5 | constructor(props) {
6 | super(props);
7 | this.state = {uploading: null};
8 | this.onDropImage = (files) => {
9 | this.setState({uploading: files});
10 | this.props.onDropImage(files, () => {
11 | if (files == this.state.uploading) {
12 | this.setState({uploading: null});
13 | }
14 | });
15 | }
16 | }
17 |
18 | componentWillReceiveProps(newProps) {
19 | if (this.state.uploading && newProps.imageURL !== props.imageURL) {
20 | this.setState({uploading: null});
21 | }
22 | }
23 |
24 | render() {
25 | if (this.state.uploading) {
26 | return Uploading ;
27 | } else {
28 | return (
29 |
35 |
36 |
37 | )
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/.meteor/packages:
--------------------------------------------------------------------------------
1 | # Meteor packages used by this project, one per line.
2 | # Check this file (and the other files in this directory) into your repository.
3 | #
4 | # 'meteor add' and 'meteor remove' will edit this file for you,
5 | # but you can also edit it by hand.
6 |
7 | meteor-base@1.0.4 # Packages every Meteor app needs to have
8 | mobile-experience@1.0.4 # Packages for a great mobile UX
9 | mongo@1.1.10 # The database Meteor supports right now
10 | blaze-html-templates@1.0.4 # Compile .html files into Meteor Blaze views
11 | reactive-var@1.0.10 # Reactive variable for tracker
12 | jquery@1.11.9 # Helpful client-side library
13 | tracker@1.1.0 # Meteor's client-side reactive programming library
14 |
15 | standard-minifier-css@1.1.8 # CSS minifier run for production mode
16 | standard-minifier-js@1.1.8 # JS minifier run for production mode
17 | es5-shim@4.6.13 # ECMAScript 5 compatibility for older browsers.
18 | ecmascript@0.5.7 # Enable ECMAScript2015+ syntax in app code
19 |
20 | react-meteor-data
21 | lepozepo:s3
22 | easy:search
23 | accounts-base@1.2.9
24 | accounts-google@1.0.10
25 | service-configuration@1.0.10
26 | email@1.1.16
27 | force-ssl@1.0.12
28 | http
29 | random
30 | meteorhacks:aggregate
31 |
--------------------------------------------------------------------------------
/imports/ui/components/SourceLink.jsx:
--------------------------------------------------------------------------------
1 | import Path from 'path';
2 | import React from 'react';
3 |
4 | export default (props) => {
5 | const currentURL = props.URL;
6 |
7 | let URLLabelNode = null;
8 | if (currentURL && currentURL !== "") {
9 | try {
10 | const URLObject = new URL(currentURL);
11 | const extension = Path.extname(URLObject.pathname);
12 | let annotation;
13 | if (extension) {
14 | annotation = extension.substring(1).toUpperCase();
15 | } else {
16 | annotation = URLObject.hostname;
17 | }
18 | URLLabelNode = (
19 |
20 | View reference [{annotation}]
21 |
22 | );
23 | }
24 | catch (ex) {
25 | console.error(ex);
26 | }
27 | }
28 |
29 | const onClick = (event) => {
30 | const newURL = window.prompt("Provide a source URL for this entry", currentURL || "");
31 | if (newURL) {
32 | props.onChange(newURL);
33 | }
34 | event.preventDefault();
35 | };
36 |
37 | return (
38 |
39 | {URLLabelNode}
40 | {
41 | props.disabled ? null :
42 |
47 | edit URL
48 |
49 | }
50 |
51 | );
52 | };
53 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hivemind",
3 | "private": true,
4 | "scripts": {
5 | "start": "meteor run"
6 | },
7 | "dependencies": {
8 | "draft-js": "^0.7.0",
9 | "draft-js-alignment-plugin": "^1.0.3",
10 | "draft-js-cleanup-empty-plugin": "^1.0.2",
11 | "draft-js-dnd-plugin": "^1.0.6",
12 | "draft-js-entity-props-plugin": "^1.0.2",
13 | "draft-js-export-html": "^0.5.2",
14 | "draft-js-focus-plugin": "^1.0.11",
15 | "draft-js-image-plugin": "^1.0.3",
16 | "draft-js-linkify-plugin": "^1.0.1",
17 | "draft-js-plugins-editor-wysiwyg": "^1.0.3",
18 | "draft-js-resizeable-plugin": "^1.0.8",
19 | "draft-js-toolbar-plugin": "^1.0.5",
20 | "immutable": "^3.7.6",
21 | "lodash": "^4.11.2",
22 | "meteor-node-stubs": "~0.2.0",
23 | "node-metainspector": "^1.3.0",
24 | "path": "^0.12.7",
25 | "react": "^15.3.2",
26 | "react-addons-css-transition-group": "^15.3.2",
27 | "react-addons-pure-render-mixin": "^15.3.0",
28 | "react-addons-shallow-compare": "^15.3.2",
29 | "react-dom": "^15.3.0",
30 | "react-dropzone": "^3.4.0",
31 | "react-input-autosize": "^0.6.13",
32 | "react-portal": "^3.0.0",
33 | "react-router": "^2.2.4",
34 | "react-selectize": "^2.0.3",
35 | "request": "^2.74.0",
36 | "url-search-params": "^0.5.0"
37 | },
38 | "devDependencies": {
39 | "eslint": "^2.7.0"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/imports/ui/components/SearchField.jsx:
--------------------------------------------------------------------------------
1 | import Lodash from 'lodash';
2 | import React from 'react';
3 | import URLSearchParams from 'url-search-params';
4 | import { browserHistory } from 'react-router';
5 |
6 | export default class SearchField extends React.Component {
7 | constructor(props) {
8 | super(props);
9 | this.state = {value: props.value || ""};
10 |
11 | this.propagateChange = Lodash.throttle(() => {
12 | const nowEmpty = this.state.value == "";
13 |
14 | let newURL = new URL(document.location);
15 | const params = new URLSearchParams(newURL.search.slice(1));
16 | if (nowEmpty) {
17 | params.delete("query");
18 | } else {
19 | params.set("query", nowEmpty ? "" : this.state.value);
20 | }
21 |
22 | newURL.search = params.toString();
23 | browserHistory.replace(newURL.toString());
24 | }, 500, {leading: false});
25 |
26 | this.onChange = (event) => {
27 | this.setState({value: event.target.value});
28 | this.propagateChange();
29 | };
30 | }
31 |
32 | render() {
33 | return {this.setState({hasFocus: true})}}
40 | onBlur={() => {this.setState({hasFocus: false})}}
41 | />
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/imports/api/server/notifications.js:
--------------------------------------------------------------------------------
1 | import { convertFromRaw } from 'draft-js';
2 | import { stateToHTML } from 'draft-js-export-html';
3 | import { Email } from 'meteor/email';
4 | import React from 'react';
5 |
6 | import { Entries, relativeURLForEntryID } from '../entries/entries.js';
7 | import DescriptionEditor from '../../ui/components/DescriptionEditor.jsx';
8 |
9 | export function sendNewEntryEmail(entryID) {
10 | const entry = Entries.findOne(entryID);
11 | if (!entry) {
12 | throw new Meteor.Error("Notifications.sendNewEntryEmail.unknownEntry", `Unknown entry ${entryID}`);
13 | }
14 |
15 | let titleAndAuthor = `"${entry.title || "(untitled)"}"`;
16 | if (entry.author) {
17 | titleAndAuthor += ` by ${entry.author}`;
18 | }
19 | let subject = `[hivemind] ${titleAndAuthor} - Discussion Thread`;
20 |
21 | let sourceLink = entry.URL ? `Original URL: ${entry.URL}
` : '';
22 | let tags = (entry.tags && entry.tags.length > 0) ? `Tags: ${entry.tags.map((tag) => `#${tag}`).join(" ")}
` : '';
23 | let image = (entry.imageURL) ? `
` : '';
24 |
25 | const entryAbsoluteURL = Meteor.absoluteUrl(relativeURLForEntryID(entry._id));
26 |
27 | let notes = '';
28 | if (entry.description) {
29 | const contentState = convertFromRaw(entry.description);
30 | notes = stateToHTML(contentState);
31 | }
32 |
33 | const html = `${titleAndAuthor} was added to Hivemind . Please reply to this thread with comments, thoughts, and discussion; or add to the entry if you have notes on the thing itself!
` +
34 | `${sourceLink}${tags}${image}` +
35 | `${notes} Search ID: ${entryID}
`;
36 |
37 | Email.send({
38 | from: Meteor.settings.notificationEmails.from,
39 | to: Meteor.settings.notificationEmails.to,
40 | replyTo: Meteor.settings.notificationEmails.replyTo,
41 | subject: subject,
42 | html: html,
43 | });
44 | }
45 |
--------------------------------------------------------------------------------
/imports/api/entries/entries.js:
--------------------------------------------------------------------------------
1 | import { Meteor } from 'meteor/meteor';
2 | import { Mongo } from 'meteor/mongo';
3 | import { EasySearch } from 'meteor/easy:search';
4 |
5 | export const entryUploadPath = "entryImages";
6 |
7 | export const Entries = new Mongo.Collection("entries");
8 |
9 | export function relativeURLForEntryID(entryID) {
10 | return `entry/${entryID}`
11 | }
12 |
13 | export function publishCollections() {
14 | if (Meteor.isServer) {
15 | Meteor.publish("entries", () => Entries.find({}));
16 | Meteor.publish("entry", (id) => Entries.find(id));
17 | }
18 | }
19 |
20 | const simpleSearchFields = ['title', 'author', 'description'];
21 | export const EntriesIndex = new EasySearch.Index({
22 | collection: Entries,
23 | fields: simpleSearchFields,
24 | engine: new EasySearch.Minimongo({
25 | selector: (searchObject, options, aggregation) => {
26 | let selector = {};
27 | let searchString = null;
28 | selector[aggregation] = [];
29 | for (let entry in searchObject) {
30 | const field = entry;
31 | searchString = searchObject[field];
32 |
33 | // Remove any tags from the search string.
34 | const searchStringWithoutTags = searchString.replace(/\s?(?:#"(.+?)"|#(.+?)(?:\s|$))\s?/g, "");
35 | let fieldSelector = {};
36 | fieldSelector[field] = { '$regex' : `.*${searchStringWithoutTags}.*`, '$options' : 'i'};
37 | selector[aggregation].push(fieldSelector)
38 | }
39 |
40 | // Now: are there tags in the search string? If so, let's parse 'em out and put them together.
41 | let tagsSelector = null;
42 | if (searchString) {
43 | const tagRegexp = /(?:#"(.+?)"|#(.+?)(?:\s|$))/g;
44 | let result;
45 | let tags = [];
46 | while ((result = tagRegexp.exec(searchString)) !== null) {
47 | tags.push(result[1] || result[2]);
48 | }
49 | if (tags.length > 0) {
50 | tagsSelector = {tags: {$all: tags}};
51 | }
52 | }
53 |
54 | // If we're searching for a tag, $and that in. Otherwise, don't bother.
55 | if (tagsSelector) {
56 | selector = {"$and": [selector, tagsSelector]};
57 | }
58 | return selector;
59 | },
60 | }),
61 | });
62 |
--------------------------------------------------------------------------------
/.meteor/versions:
--------------------------------------------------------------------------------
1 | accounts-base@1.2.9
2 | accounts-google@1.0.10
3 | accounts-oauth@1.1.13
4 | allow-deny@1.0.5
5 | autoupdate@1.2.11
6 | babel-compiler@6.9.0
7 | babel-runtime@0.1.10
8 | base64@1.0.9
9 | binary-heap@1.0.9
10 | blaze@2.1.8
11 | blaze-html-templates@1.0.4
12 | blaze-tools@1.0.9
13 | boilerplate-generator@1.0.9
14 | caching-compiler@1.0.6
15 | caching-html-compiler@1.0.6
16 | callback-hook@1.0.9
17 | check@1.2.3
18 | coffeescript@1.0.17
19 | ddp@1.2.5
20 | ddp-client@1.2.9
21 | ddp-common@1.2.6
22 | ddp-rate-limiter@1.0.5
23 | ddp-server@1.2.10
24 | deps@1.0.12
25 | diff-sequence@1.0.6
26 | easy:search@2.0.9
27 | easysearch:components@2.0.9
28 | easysearch:core@2.0.9
29 | ecmascript@0.5.7
30 | ecmascript-runtime@0.3.12
31 | ejson@1.0.12
32 | email@1.1.16
33 | es5-shim@4.6.13
34 | fastclick@1.0.12
35 | force-ssl@1.0.12
36 | geojson-utils@1.0.9
37 | google@1.1.13
38 | hot-code-push@1.0.4
39 | html-tools@1.0.10
40 | htmljs@1.0.10
41 | http@1.1.8
42 | id-map@1.0.8
43 | jquery@1.11.9
44 | launch-screen@1.0.12
45 | lepozepo:s3@5.2.1
46 | livedata@1.0.18
47 | localstorage@1.0.11
48 | logging@1.1.14
49 | meteor@1.2.16
50 | meteor-base@1.0.4
51 | meteorhacks:aggregate@1.3.0
52 | meteorhacks:collection-utils@1.2.0
53 | minifier-css@1.2.13
54 | minifier-js@1.2.13
55 | minimongo@1.0.17
56 | mobile-experience@1.0.4
57 | mobile-status-bar@1.0.12
58 | modules@0.7.5
59 | modules-runtime@0.7.5
60 | mongo@1.1.10
61 | mongo-id@1.0.5
62 | mongo-livedata@1.0.12
63 | npm-mongo@1.5.45
64 | oauth@1.1.11
65 | oauth2@1.1.10
66 | observe-sequence@1.0.12
67 | ordered-dict@1.0.8
68 | peerlibrary:assert@0.2.5
69 | peerlibrary:base-component@0.14.0
70 | peerlibrary:blaze-components@0.16.2
71 | peerlibrary:computed-field@0.3.1
72 | peerlibrary:data-lookup@0.1.0
73 | peerlibrary:reactive-field@0.1.0
74 | promise@0.8.3
75 | random@1.0.10
76 | rate-limit@1.0.5
77 | react-meteor-data@0.2.9
78 | reactive-dict@1.1.8
79 | reactive-var@1.0.10
80 | reload@1.1.10
81 | retry@1.0.8
82 | routepolicy@1.0.11
83 | service-configuration@1.0.10
84 | spacebars@1.0.12
85 | spacebars-compiler@1.0.12
86 | standard-minifier-css@1.1.8
87 | standard-minifier-js@1.1.8
88 | templating@1.1.14
89 | templating-tools@1.0.4
90 | tmeasday:check-npm-versions@0.2.0
91 | tracker@1.1.0
92 | ui@1.0.11
93 | underscore@1.0.9
94 | url@1.0.10
95 | webapp@1.3.10
96 | webapp-hashing@1.0.9
97 |
--------------------------------------------------------------------------------
/imports/ui/components/EntryList.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import Portal from 'react-portal';
3 | import { browserHistory } from 'react-router';
4 | import URLSearchParams from 'url-search-params';
5 |
6 | import Entry from './Entry.jsx';
7 | import EntryCell from './EntryCell.jsx';
8 |
9 | export default class EntryList extends React.Component {
10 | render() {
11 | const props = this.props;
12 |
13 | const clearFocusedEntry = (event) => {
14 | let newURL = new URL(document.location);
15 | const params = new URLSearchParams(newURL.search.slice(1));
16 | params.delete("entry");
17 | newURL.search = params.toString();
18 | browserHistory.replace(newURL.toString());
19 |
20 | if (event) {
21 | event.preventDefault();
22 | }
23 | };
24 |
25 | const propsForEntry = (entry) => {
26 | return {
27 | key: entry._id,
28 | entry,
29 | onChange: props.onChangeEntry,
30 | onDelete: () => {
31 | clearFocusedEntry();
32 | props.onDeleteEntry(entry._id);
33 | },
34 | onDropImage: (files, callback) => props.onDropImage(entry._id, files, callback),
35 | onChangeRecommending: (isNewlyRecommending) => props.onChangeRecommending(entry._id, isNewlyRecommending),
36 | onChangeViewing: (isNewlyViewing) => props.onChangeViewing(entry._id, isNewlyViewing),
37 | onChangeURL: (newURL) => props.onChangeURL(entry._id, newURL),
38 | onStartDiscussionThread: () => {props.onStartDiscussionThread(entry._id)},
39 | disabled: props.disabled,
40 | };
41 | };
42 |
43 | const { focusedEntry } = props;
44 | return
45 | {props.entries.map((entry) => (
46 |
49 | ))}
50 |
51 | {
52 | if (event.target === this.refs.lightboxBackground) {
53 | this.refs.lightbox.closePortal();
54 | event.stopPropagation();
55 | }
56 | }} ref="lightboxBackground">
57 | {focusedEntry ? ( ) : "" }
61 |
62 |
63 |
;
64 | }
65 | };
66 |
--------------------------------------------------------------------------------
/client/toolbar-plugin.css:
--------------------------------------------------------------------------------
1 | /* Tooltip */
2 | [data-tooltip] {
3 | position: relative;
4 | text-decoration: none;
5 | }
6 |
7 | [data-tooltip]:after {
8 | content: attr(data-tooltip);
9 | position: absolute;
10 | bottom: 130%;
11 | left: 0%;
12 | background: #000000;
13 | padding: 5px 15px;
14 | color: #ffffff;
15 | border-radius: 10px;
16 | white-space: nowrap;
17 | opacity: 0;
18 | -webkit-transition: all 0.4s ease;
19 | transition: all 0.4s ease;
20 | pointer-events: none;
21 | }
22 |
23 | [data-tooltip]:hover:after {
24 | bottom: 110%;
25 | }
26 |
27 | [data-tooltip]:hover:after, a:hover:before {
28 | opacity: 1;
29 | }
30 |
31 | /* Sidebar */
32 | .draftJsToolbar__draft-sidebar__iGLU3 {
33 | background-color: transparent;
34 | border: none;
35 | border-radius: 50px;
36 | font-size: 16px;
37 | width: 32px;
38 | }
39 |
40 | .draftJsToolbar__draft-sidebar__iGLU3 .draftJsToolbar__item__16yus button {
41 | background-color: transparent;
42 | cursor: pointer;
43 | border: 1px solid rgba(0,0,0,.6);
44 | border-radius: 50px;
45 | font-size: 16px;
46 | color: rgba(0,0,0,.6);
47 | height: 32px;
48 | line-height: 31px;
49 | padding: 0 14px;
50 | font-size: 12px;
51 | width: 32px;
52 | height: 32px;
53 | padding: 0;
54 | }
55 |
56 | .draftJsToolbar__draft-sidebar__iGLU3 .draftJsToolbar__item__16yus .draftJsToolbar__menu__2kTke {
57 | display: none
58 | }
59 |
60 | .draftJsToolbar__draft-sidebar__iGLU3 .draftJsToolbar__item__16yus:hover .draftJsToolbar__menu__2kTke {
61 | display: block
62 | }
63 |
64 | /* Toolbar */
65 | .draftJsToolbar__toolbar__2NV21 {
66 | background-color: #000;
67 | border: none;
68 | border-radius: 50px;
69 | font-size: 16px;
70 | }
71 |
72 | .draftJsToolbar__toolbar__2NV21 .draftJsToolbar__toolbar-item__ZQoML {
73 | float: left;
74 | list-style: none;
75 | margin: 0;
76 | padding: 0;
77 | background-color: #000;
78 | }
79 |
80 | .draftJsToolbar__toolbar__2NV21 .draftJsToolbar__toolbar-item__ZQoML:first-child {
81 | border-bottom-left-radius: 50px;
82 | border-top-left-radius: 50px;
83 | padding-left: 6px;
84 | }
85 |
86 | .draftJsToolbar__toolbar__2NV21 .draftJsToolbar__toolbar-item__ZQoML:last-child {
87 | border-bottom-right-radius: 50px;
88 | border-top-right-radius: 50px;
89 | padding-right: 6px;
90 | }
91 |
92 | .draftJsToolbar__toolbar__2NV21 .draftJsToolbar__toolbar-item__ZQoML.draftJsToolbar__toolbar-item-active__1MJoi {
93 | background-color: #2f2f2f;
94 | }
95 |
96 | .draftJsToolbar__toolbar__2NV21 .draftJsToolbar__toolbar-item__ZQoML:hover{
97 | background-color: #2f2f2f;
98 | }
99 |
100 | .draftJsToolbar__toolbar__2NV21 .draftJsToolbar__toolbar-item__ZQoML button {
101 | background-color: transparent;
102 | border: 0;
103 | color: white;
104 | box-sizing: border-box;
105 | cursor: pointer;
106 | display: block;
107 | font-size: 14px;
108 | line-height: 1.33;
109 | margin: 0;
110 | padding: 15px;
111 | text-decoration: none;
112 | }
--------------------------------------------------------------------------------
/imports/ui/components/EntryCell.jsx:
--------------------------------------------------------------------------------
1 | import Draft from 'draft-js';
2 | import React from 'react';
3 | import URLSearchParams from 'url-search-params';
4 | import { browserHistory } from 'react-router';
5 |
6 | import ToggleList from './ToggleList.jsx';
7 |
8 | // Represents a single hivemind database entry--the collapsed view as it appears in a list.
9 | export default class EntryCell extends React.Component {
10 | shouldComponentUpdate(nextProps, nextState) {
11 | if (nextState !== this.state) {
12 | return true;
13 | } else {
14 | return nextProps.entry.updatedAt.getTime() !== this.props.entry.updatedAt.getTime();
15 | }
16 | }
17 |
18 | navigate() {
19 | const newURL = new URL(document.location);
20 | const params = new URLSearchParams(newURL.search.slice(1));
21 | params.set("entry", this.props.entry._id);
22 | newURL.search = params.toString();
23 | browserHistory.replace(newURL.toString());
24 | }
25 |
26 | render() {
27 | let author = null;
28 |
29 | let recommenderList = ;
35 | if (!this.props.entry.recommenders || this.props.entry.recommenders.length == 0) {
36 | recommenderList = null;
37 | }
38 |
39 | if (this.props.entry.author) {
40 | author =
41 | by {this.props.entry.author}
42 |
43 | }
44 |
45 | let description = null;
46 | if (this.props.entry.description) {
47 | const contentState = Draft.convertFromRaw(this.props.entry.description);
48 | const descriptionText = contentState.getPlainText();
49 | if (descriptionText.length > 0) {
50 | description = descriptionText.substr(0, 300);
51 | }
52 | }
53 |
54 | let titleAndAuthor;
55 | if (description) {
56 | titleAndAuthor =
57 |
58 | {this.props.entry.title}
59 |
60 | {author}
61 |
;
62 | } else {
63 | titleAndAuthor =
64 | {this.props.entry.title} [pending]{author}
65 |
;
66 | }
67 |
68 | let imageURL = this.props.entry.imageURL;
69 | let imageNode;
70 | if (imageURL) {
71 | imageNode = ;
72 | } else {
73 | imageNode =
;
74 | }
75 |
76 | return {this.navigate(); e.preventDefault();}}>
77 |
78 | {imageNode}
79 |
80 |
81 |
82 | {titleAndAuthor}
83 |
84 | {recommenderList}
85 |
86 |
87 |
88 | {description}
89 |
90 |
91 | ;
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/imports/ui/EntryPage.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { createContainer } from 'meteor/react-meteor-data';
3 | import { IndexLink, Link, browserHistory } from 'react-router';
4 |
5 | import Entry from './components/Entry.jsx';
6 | import UserButton from './components/UserButton';
7 | import { Entries } from '../api/entries/entries.js';
8 | import uploadEntryImage from '../api/client/uploadEntryImage';
9 | import materializeEntryUsers from '../materializeEntryUsers';
10 |
11 | class EntryPage extends Component {
12 | // TODO: Fix duplication with Home.jsx.
13 | updateEntry(newEntry) {
14 | Meteor.call("entry.update", {entryID: newEntry._id, newEntry});
15 | }
16 |
17 | deleteEntry(entryID) {
18 | Meteor.call("entry.remove", {entryID});
19 | browserHistory.push("/");
20 | }
21 |
22 | changeRecommending(entryID, isNewlyRecommending) {
23 | Meteor.call("entry.updateRecommender", {entryID: entryID, isNewlyRecommending: isNewlyRecommending});
24 | }
25 |
26 | changeViewing(entryID, isNewlyViewing) {
27 | Meteor.call("entry.updateViewer", {entryID: entryID, isNewlyViewing: isNewlyViewing});
28 | }
29 |
30 | changeURL(entryID, newURL) {
31 | Meteor.call("entry.setURL", {entryID: entryID, URL: newURL});
32 | }
33 |
34 | startDiscussionThread(entryID) {
35 | Meteor.call("entry.startDiscussionThread", {entryID: entryID});
36 | }
37 |
38 | render() {
39 | if (!this.props.ready) {
40 | return Loading... ;
41 | }
42 |
43 | console.log(this.props.entry)
44 | if (this.props.entry) {
45 | const { entry, user } = this.props;
46 | return (
47 |
48 |
53 | this.changeRecommending(entry._id, isNewlyRecommending)}
57 | onChangeViewing={(isNewlyViewing) => this.changeViewing(entry._id, isNewlyViewing)}
58 | onChangeURL={this.changeURL}
59 | onDelete={() => this.deleteEntry(entry._id)}
60 | onDropImage={(files, callback) => {uploadEntryImage(entry._id, files, callback)}}
61 | onStartDiscussionThread={this.startDiscussionThread}
62 | disabled={this.props.user === null}
63 | allTags={this.props.tags}
64 | />
65 |
66 | );
67 | } else {
68 | return Can't find entry! ;
69 | }
70 | }
71 | }
72 |
73 | const tagEntriesReactiveVar = new ReactiveVar(null);
74 |
75 | export default createContainer((props) => {
76 | const entryID = props.params.entryID;
77 |
78 | const entrySubscription = Meteor.subscribe("entry", entryID);
79 | const usersSubscription = Meteor.subscribe("users");
80 |
81 | if (tagEntriesReactiveVar.get() == null) {
82 | Meteor.call("entries.fetchAllTagEntriesSortedDescending", (error, response) => {
83 | tagEntriesReactiveVar.set(response);
84 | });
85 | tagEntriesReactiveVar.set([]);
86 | }
87 |
88 | return {
89 | entry: Entries.findOne(entryID, {transform: materializeEntryUsers}),
90 | tags: tagEntriesReactiveVar.get(),
91 | user: Meteor.user(),
92 | ready: entrySubscription.ready() && usersSubscription.ready(),
93 | };
94 | }, EntryPage);
95 |
--------------------------------------------------------------------------------
/client/Draft.css:
--------------------------------------------------------------------------------
1 | /* This should be imported via npm actions, but that's broken in Meteor now:
2 | https://github.com/meteor/meteor/issues/6037. Remove this file once that's
3 | fixed! */
4 |
5 | /**
6 | * Draft v0.4.0
7 | *
8 | * Copyright (c) 2013-present, Facebook, Inc.
9 | * All rights reserved.
10 | *
11 | * This source code is licensed under the BSD-style license found in the
12 | * LICENSE file in the root directory of this source tree. An additional grant
13 | * of patent rights can be found in the PATENTS file in the same directory.
14 | */
15 | .DraftEditor-editorContainer,.DraftEditor-root,.public-DraftEditor-content{height:inherit;text-align:initial}.DraftEditor-root{position:relative}.DraftEditor-editorContainer{background-color:rgba(255,255,255,0);border-left:.1px solid transparent;position:relative;z-index:1}.public-DraftEditor-block{position:relative}.DraftEditor-alignLeft .public-DraftStyleDefault-block{text-align:left}.DraftEditor-alignLeft .public-DraftEditorPlaceholder-root{left:0;text-align:left}.DraftEditor-alignCenter .public-DraftStyleDefault-block{text-align:center}.DraftEditor-alignCenter .public-DraftEditorPlaceholder-root{margin:0 auto;text-align:center;width:100%}.DraftEditor-alignRight .public-DraftStyleDefault-block{text-align:right}.DraftEditor-alignRight .public-DraftEditorPlaceholder-root{right:0;text-align:right}.public-DraftEditorPlaceholder-root{color:#9197a3;position:absolute;z-index:0}.public-DraftEditorPlaceholder-hasFocus{color:#bdc1c9}.DraftEditorPlaceholder-hidden{display:none}.public-DraftStyleDefault-block{position:relative;white-space:pre-wrap}.public-DraftStyleDefault-ltr{direction:ltr;text-align:left}.public-DraftStyleDefault-rtl{direction:rtl;text-align:right}.public-DraftStyleDefault-listLTR{direction:ltr}.public-DraftStyleDefault-listRTL{direction:rtl}.public-DraftStyleDefault-ol,.public-DraftStyleDefault-ul{margin:16px 0;padding:0}.public-DraftStyleDefault-depth0.public-DraftStyleDefault-listLTR{margin-left:1.5em}.public-DraftStyleDefault-depth0.public-DraftStyleDefault-listRTL{margin-right:1.5em}.public-DraftStyleDefault-depth1.public-DraftStyleDefault-listLTR{margin-left:3em}.public-DraftStyleDefault-depth1.public-DraftStyleDefault-listRTL{margin-right:3em}.public-DraftStyleDefault-depth2.public-DraftStyleDefault-listLTR{margin-left:4.5em}.public-DraftStyleDefault-depth2.public-DraftStyleDefault-listRTL{margin-right:4.5em}.public-DraftStyleDefault-depth3.public-DraftStyleDefault-listLTR{margin-left:6em}.public-DraftStyleDefault-depth3.public-DraftStyleDefault-listRTL{margin-right:6em}.public-DraftStyleDefault-depth4.public-DraftStyleDefault-listLTR{margin-left:7.5em}.public-DraftStyleDefault-depth4.public-DraftStyleDefault-listRTL{margin-right:7.5em}.public-DraftStyleDefault-unorderedListItem{list-style-type:square;position:relative}.public-DraftStyleDefault-unorderedListItem.public-DraftStyleDefault-depth0{list-style-type:disc}.public-DraftStyleDefault-unorderedListItem.public-DraftStyleDefault-depth1{list-style-type:circle}.public-DraftStyleDefault-orderedListItem{list-style-type:none;position:relative}.public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-listLTR:before{left:-36px;position:absolute;text-align:right;width:30px}.public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-listRTL:before{position:absolute;right:-36px;text-align:left;width:30px}.public-DraftStyleDefault-orderedListItem:before{content:counter(ol0) ". ";counter-increment:ol0}.public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-depth1:before{content:counter(ol1) ". ";counter-increment:ol1}.public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-depth2:before{content:counter(ol2) ". ";counter-increment:ol2}.public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-depth3:before{content:counter(ol3) ". ";counter-increment:ol3}.public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-depth4:before{content:counter(ol4) ". ";counter-increment:ol4}.public-DraftStyleDefault-depth0.public-DraftStyleDefault-reset{counter-reset:ol0}.public-DraftStyleDefault-depth1.public-DraftStyleDefault-reset{counter-reset:ol1}.public-DraftStyleDefault-depth2.public-DraftStyleDefault-reset{counter-reset:ol2}.public-DraftStyleDefault-depth3.public-DraftStyleDefault-reset{counter-reset:ol3}.public-DraftStyleDefault-depth4.public-DraftStyleDefault-reset{counter-reset:ol4}
16 |
--------------------------------------------------------------------------------
/imports/ui/components/TagEditor.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { Link } from 'react-router';
4 | import { MultiSelect } from 'react-selectize';
5 | import URLSearchParams from 'url-search-params';
6 |
7 | import 'react-selectize/themes/index.css';
8 |
9 | export default class TagEditor extends React.Component {
10 | constructor(props) {
11 | super(props);
12 | this.state = {
13 | isFocused: false,
14 | dropdownDirection: 1,
15 | };
16 |
17 | this.onScroll = ((event) => {
18 | const screenTop = ReactDOM.findDOMNode(this.refs.select).offsetTop - (event.target.scrollTop || document.documentElement.scrollTop);
19 | dropdownDirection = (event.target.offsetHeight - screenTop) < 215 ? -1 : 1
20 | if (this.state.dropdownDirection != dropdownDirection)
21 | this.setState({dropdownDirection: dropdownDirection});
22 | }).bind(this);
23 | }
24 |
25 | findScrollingParent() {
26 | const node = ReactDOM.findDOMNode(this);
27 | let currentNode = node;
28 | while (currentNode) {
29 | if (window.getComputedStyle(currentNode).getPropertyValue("overflow-y") == "scroll") {
30 | return currentNode;
31 | }
32 | currentNode = currentNode.parentNode;
33 | }
34 | return window;
35 | }
36 |
37 | componentDidMount() {
38 | const scrollingParent = this.findScrollingParent();
39 | scrollingParent.addEventListener("scroll", this.onScroll);
40 | }
41 |
42 | componentWillUnmount() {
43 | const scrollingParent = this.findScrollingParent();
44 | scrollingParent.removeEventListener("scroll", this.onScroll);
45 | }
46 |
47 | render() {
48 | const propItems = (this.props.tags || []).map((tag) => {
49 | return {value: tag, label: tag}
50 | });
51 | const items = this.state.isFocused ? this.state.items : propItems;
52 |
53 | const options = this.props.allTags.map((tag) => {
54 | return {value: tag.tag, label: tag.tag, count: tag.count}
55 | })
56 |
57 | return {
63 | this.setState({
64 | isFocused: true,
65 | items: propItems
66 | })
67 | }}
68 |
69 | onBlur = {() => {
70 | this.setState({
71 | isFocused: false
72 | })
73 | }}
74 |
75 | // createFromSearch :: [Item] -> [Item] -> String -> Item?
76 | createFromSearch = {function(options, values, search){
77 | labels = values.map(function(value){
78 | return value.label;
79 | })
80 | if (search.trim().length == 0 || labels.indexOf(search.trim()) != -1)
81 | return null;
82 | return {label: search.trim(), value: search.trim()};
83 | }}
84 |
85 | values = {items}
86 | options = {options}
87 |
88 | onValuesChange = {(items) => {
89 | this.setState({items: items});
90 |
91 | const tags = items.map((item) => { return item.value; });
92 | this.props.onChange(tags);
93 | }}
94 |
95 | renderOption = {(option) => {
96 | if (option.newOption) {
97 | return
98 | Add ‘{option.label}’…
99 |
;
100 | } else {
101 | return
102 | {option.label} ({option.count})
103 |
;
104 | }
105 | }}
106 |
107 | placeholder = "Tags"
108 |
109 | renderToggleButton = {() => { return null; }}
110 | hideResetButton = {true}
111 |
112 | renderValue = {(item) => {
113 | return ;
114 | }}
115 |
116 | />;
117 | }
118 | }
119 |
120 | const Tag = (props) => {
121 | const tagName = props.tag;
122 |
123 | const params = new URLSearchParams();
124 | params.set("query", `#"${tagName}"`);
125 |
126 | const newURL = new URL(document.location.origin);
127 | newURL.search = params.toString();
128 | return #{tagName}
132 | };
133 |
--------------------------------------------------------------------------------
/imports/ui/components/DescriptionEditor.jsx:
--------------------------------------------------------------------------------
1 | import Draft, {EditorState, ContentState} from 'draft-js';
2 | import createAlignmentPlugin, { AlignmentDecorator } from 'draft-js-alignment-plugin';
3 | import createCleanupEmptyPlugin from 'draft-js-cleanup-empty-plugin';
4 | import createDndPlugin, { DraggableDecorator } from 'draft-js-dnd-plugin';
5 | import addBlock from 'draft-js-dnd-plugin/lib/modifiers/addBlock.js';
6 | import createEntityPropsPlugin from 'draft-js-entity-props-plugin';
7 | import createFocusPlugin, { FocusDecorator } from 'draft-js-focus-plugin';
8 | import createImagePlugin, { imageCreator, imageStyles } from 'draft-js-image-plugin';
9 | import createLinkifyPlugin from 'draft-js-linkify-plugin';
10 | import Editor from 'draft-js-plugins-editor-wysiwyg';
11 | import createResizeablePlugin, { ResizeableDecorator } from 'draft-js-resizeable-plugin';
12 | import createToolbarPlugin, { ToolbarDecorator } from 'draft-js-toolbar-plugin';
13 | import Immutable from 'immutable';
14 | import Lodash from 'lodash';
15 | import React from 'react';
16 |
17 | const imageComponent = ResizeableDecorator({
18 | resizeSteps: 10,
19 | handles: true,
20 | vertical: 'auto'
21 | })(
22 | DraggableDecorator(
23 | FocusDecorator(
24 | AlignmentDecorator(
25 | ToolbarDecorator()(
26 | imageCreator({ theme: imageStyles })
27 | )
28 | )
29 | )
30 | )
31 | );
32 |
33 | // Init Plugins
34 | const plugins = [
35 | createCleanupEmptyPlugin({
36 | types: ['block-image']
37 | }),
38 | createEntityPropsPlugin({ }),
39 | createToolbarPlugin({}),
40 | createFocusPlugin({}),
41 | createAlignmentPlugin({}),
42 | createDndPlugin({
43 | allowDrop: true,
44 | handleUpload: (data, success, failed, progress) =>
45 | console.log("UPLOAD"),
46 | handlePlaceholder: (state, selection, data) => {
47 | const { type } = data;
48 | if (type.indexOf('image/') === 0) {
49 | return 'block-image';
50 | } return undefined;
51 | }, handleBlock: (state, selection, data) => {
52 | const { type } = data;
53 | if (type.indexOf('image/') === 0) {
54 | return 'block-image';
55 | } return undefined;
56 | },
57 | }),
58 | createLinkifyPlugin(),
59 | createResizeablePlugin({}),
60 | createImagePlugin({ component: imageComponent }),
61 | ];
62 |
63 | export default class DescriptionEditor extends React.Component {
64 | constructor(props) {
65 | super(props);
66 | const editorState = EditorState.createWithContent(this.rawContentStateToContentState(props.value));
67 | this.state = {editorState};
68 | this.onFocus = () => this.refs.editor.focus();
69 |
70 | this.propagateChange = Lodash.debounce(() => {
71 | const currentContentState = this.state.editorState.getCurrentContent();
72 | const oldContentState = this.rawContentStateToContentState(this.props.value);
73 | if (!currentContentState.equals(oldContentState)) {
74 | this.props.onChange(Draft.convertToRaw(currentContentState));
75 | }
76 | }, 3000);
77 |
78 | this.onChange = (editorState) => {
79 | this.setState({editorState});
80 | this.propagateChange();
81 | };
82 |
83 | this.onBlur = () => {
84 | this.props.onChange(Draft.convertToRaw(this.state.editorState.getCurrentContent()));
85 | }
86 |
87 | this.handleKeyCommand = (command) => {
88 | const newEditorState = Draft.RichUtils.handleKeyCommand(this.state.editorState, command);
89 | if (newEditorState) {
90 | this.onChange(newEditorState);
91 | return true;
92 | } else {
93 | return false;
94 | }
95 | }
96 | }
97 |
98 | rawContentStateToContentState(rawContentState) {
99 | if (rawContentState) {
100 | return Draft.convertFromRaw(rawContentState);
101 | } else {
102 | return ContentState.createFromText("");
103 | }
104 | }
105 |
106 | shouldComponentUpdate(nextProps, nextState) {
107 | return nextState !== this.state || nextProps.disabled !== this.props.disabled;
108 | }
109 |
110 | componentWillReceiveProps(nextProps) {
111 | const {editorState} = this.state;
112 | const newContentState = this.rawContentStateToContentState(nextProps.value);
113 | if (!editorState.getSelection().hasFocus &&
114 | !newContentState.getBlockMap().equals(editorState.getCurrentContent().getBlockMap())) {
115 | this.setState({editorState: EditorState.push(editorState, newContentState)});
116 | }
117 | }
118 |
119 | render() {
120 | return (
121 |
122 |
132 |
133 | );
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/imports/ui/Home.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { createContainer } from 'meteor/react-meteor-data';
3 | import { IndexLink, browserHistory } from 'react-router';
4 | import URLSearchParams from 'url-search-params';
5 |
6 | import EntryList from './components/EntryList.jsx';
7 | import SearchField from './components/SearchField.jsx';
8 | import UserButton from './components/UserButton.jsx';
9 | import { Entries, EntriesIndex } from '../api/entries/entries.js';
10 | import uploadEntryImage from '../api/client/uploadEntryImage';
11 | import materializeEntryUsers from '../materializeEntryUsers';
12 |
13 | // Represents the standard UI
14 | class Home extends Component {
15 | constructor(props) {
16 | super(props);
17 | this.addEntry = () => {
18 | // TODO: Reimplement the tag-dependent creation feature for unified search.
19 | Meteor.call("entry.create", {tags: []}, (error, newEntryID) => {
20 | // TODO: remove duplication here.
21 | if (newEntryID) {
22 | const newURL = new URL(document.location);
23 | const params = new URLSearchParams(newURL.search.slice(1));
24 | params.set("entry", newEntryID);
25 | newURL.search = params.toString();
26 | browserHistory.replace(newURL.toString());
27 | } else {
28 | console.error(error);
29 | }
30 | })
31 | }
32 | }
33 |
34 | updateEntry(newEntry) {
35 | Meteor.call("entry.update", {entryID: newEntry._id, newEntry});
36 | }
37 |
38 | deleteEntry(entryID) {
39 | Meteor.call("entry.remove", {entryID});
40 | }
41 |
42 | changeRecommending(entryID, isNewlyRecommending) {
43 | Meteor.call("entry.updateRecommender", {entryID: entryID, isNewlyRecommending: isNewlyRecommending});
44 | }
45 |
46 | changeViewing(entryID, isNewlyViewing) {
47 | Meteor.call("entry.updateViewer", {entryID: entryID, isNewlyViewing: isNewlyViewing});
48 | }
49 |
50 | changeURL(entryID, newURL) {
51 | Meteor.call("entry.setURL", {entryID: entryID, URL: newURL});
52 | }
53 |
54 | startDiscussionThread(entryID) {
55 | Meteor.call("entry.startDiscussionThread", {entryID: entryID});
56 | }
57 |
58 | render() {
59 | if (!this.props.ready) {
60 | return Loading... ;
61 | }
62 |
63 | return (
64 |
87 | );
88 | }
89 | }
90 |
91 | const tagEntriesReactiveVar = new ReactiveVar(null);
92 |
93 | export default createContainer((props) => {
94 | const entriesSubscription = Meteor.subscribe("entries");
95 | const usersSubscription = Meteor.subscribe("users");
96 |
97 | const { query, entry } = props.location.query;
98 | let entriesCursor = Entries.find({}, {sort: [["createdAt", "desc"]]});
99 | let focusedEntry = null;
100 | if (query && query.length > 0) {
101 | entriesCursor = EntriesIndex.search(query);
102 | } else {
103 | entriesCursor = Entries.find({}, {sort: [["createdAt", "desc"]]});
104 | }
105 | const entries = entriesCursor.fetch()
106 |
107 | if (entry && entry.length > 0) {
108 | focusedEntry = Entries.findOne(entry);
109 | if (focusedEntry) {
110 | focusedEntry = materializeEntryUsers(focusedEntry);
111 | }
112 | }
113 |
114 | const updateTagEntries = () => {
115 | Meteor.call("entries.fetchAllTagEntriesSortedDescending", (error, response) => {
116 | tagEntriesReactiveVar.set(response);
117 | });
118 | };
119 | Entries.find({}).observe({
120 | added: () => {
121 | if (tagEntriesReactiveVar.get() === null) {
122 | updateTagEntries();
123 | tagEntriesReactiveVar.set([]);
124 | }
125 | },
126 | changed: updateTagEntries,
127 | removed: updateTagEntries
128 | });
129 |
130 | return {
131 | entries: entries.map(materializeEntryUsers), // TODO: remove eagerness?,
132 | tags: tagEntriesReactiveVar.get(),
133 | query: query,
134 | user: Meteor.user(),
135 | ready: entriesSubscription.ready() && usersSubscription.ready(),
136 | focusedEntry: focusedEntry,
137 | };
138 | }, Home);
139 |
--------------------------------------------------------------------------------
/Readme.md:
--------------------------------------------------------------------------------
1 | # Hivemind
2 |
3 | Hivemind is an experimental knowledge-management system built to help Khan Academy's Long-term Research group share intellectual context.
4 |
5 | In the course of our research, we review lots of papers, books, games, toys, etc. We’re mining for quotes, great ideas, other promising resources, inspiration. When we find something great, that becomes part of our research team’s shared intellectual context—one of the most valuable things we build!
6 |
7 | 
8 |
9 | Disclaimers: The project is still very young, and it's probably not useful outside of Khan Academy. It's still in a rough prototype stage, intentionally not yet robustly architected.
10 |
11 | ## Running a local server
12 |
13 | First, [install Meteor](https://www.meteor.com/install): `curl https://install.meteor.com/ | sh`
14 |
15 | This app requires valid credentials for Amazon S3, Google OAuth, and SMTP.
16 |
17 | If you're at Khan Academy, copy the [secrets from Phabricator](https://phabricator.khanacademy.org/K145) into `settings.json` in the root of the project directory. If you're not, modify `settings.template.json` to use your secrets.
18 |
19 | Install local dependencies with `npm install`
20 |
21 | Run a local server with `meteor --settings settings.json`.
22 |
23 | ## Deploying to heroku
24 |
25 | Aside from a heroku account, you need mongodb and a heroku buildpack to get hivemind working for yourself. Create a new app on heroku, let's pretend you called it **hivehive**. This section assumes no knowledge of heroku. You will need to install the [heroku toolbelt](https://toolbelt.heroku.com/) to run command line heroku commands instead of using their web interface to `init`.
26 |
27 | 1. For the buildpack, you will want *[the one with the horse](https://github.com/AdmitHub/meteor-buildpack-horse)*. You can set it up by typing the following in the project dir:
28 |
29 | ```shell
30 | heroku buildpacks:set https://github.com/AdmitHub/meteor-buildpack-horse.git
31 | ```
32 |
33 | optionally, you can set that url in the `settings` tab of your app via the heroku dashboard
34 |
35 | 1. For mongo, you can use the **mLab Mongo** add-on at the **sandbox** tier (up to 500MB) for free (but you may need to verify your heroku acct using a credit card). You'll get a url for connecting to mongo. mLab will add a mongo url to your heroku project's environmental variables. The *horse buildpack* prefers the `MONGO_URL` environmental variable but you can use `MONGODB_URI`.
36 |
37 | 1. The `ROOT_URL` environmental variable will be `https://hivehive.herokuapp.com/` yes, the protocol is http**s**.
38 |
39 | 1. To deploy via git, you can add heroku as a remote like so
40 |
41 | ```shell
42 | heroku git:remote -a hivehive
43 | # or via git proper
44 | git add remote heroku https://git.heroku.com/hivehive.git
45 | ```
46 |
47 | 1. Copy your `settings.json` file into the `METEOR_SETTINGS` environmental variable in the heroku dashboard.
48 |
49 | 1. To actually deploy, you "simply" push to the heroku master branch, like so:
50 |
51 | ```shell
52 | git push heroku master
53 | ```
54 |
55 | This process easily takes a few minutes at best. Have a lemonade.
56 |
57 | The site will now work, but you will need to make sure that your [s3 bucket is world-readable](http://stackoverflow.com/a/4709391/470756). If not, hivemind will successfully upload media to your bucket (assuming your iam access/secrets are good and the [policy you assigned](https://aws.amazon.com/blogs/security/writing-iam-policies-how-to-grant-access-to-an-amazon-s3-bucket/) to your user is valid), but your meteor server will be full of apparently broken links.
58 |
59 | If you are looking for a free smtp service, mailgun has a *very generous* free tier.
60 |
61 | ## Google OAuth Configuration
62 |
63 | To get OAuth working you will need to sign in to the [Google Cloud Platform](console.cloud.google.com/) and create a new project. Which can be done by clicking the Select a project dropdown in the upper left corner and then clicking the + icon.
64 |
65 | Once your new project has been created use the search field to look for "Credentials" and select it.
66 |
67 |
68 |
69 | Then create a new set of credentials using the Oauth client ID option.
70 |
71 |
72 |
73 | When selecting the application type, check the web application and give it a name. Then add your localhost and Heroku app to the authorized URIs. Click create and copy the clientId and secret from the pop-up.
74 |
75 |
76 |
77 | In the settings.json replace the null verbal for clientId and secret with the one from the pop-up.
78 |
79 | ```
80 | "google": {
81 | "clientId": 0000000000000-qfglu1eb95aeo8io4dap4rlu8rtfani4.apps.googleusercontent.com,
82 | "secret": od4FF2pWouXVk4aMLf59--OO
83 | },
84 | ```
85 |
86 | ## Deploying to Khan Academy's Hivemind instance
87 |
88 | (Intentionally not yet linking to the instance publicly—still evolving…)
89 |
90 | The Long-term Research Hivemind currently runs on Heroku. Ask [Andy](mailto:andy@khanacademy.org) for push access, then:
91 |
92 | 1. [Install the Heroku toolbelt](https://toolbelt.heroku.com/).
93 | 2. Add Heroku's remote in your git checkout: `heroku git:remote -a ka-hivemind`
94 | 3. Push your branch to Heroku: `git push origin heroku`
95 |
--------------------------------------------------------------------------------
/imports/api/entries/methods.js:
--------------------------------------------------------------------------------
1 | import { Entries, entryUploadPath } from './entries.js';
2 |
3 | import { Meteor } from 'meteor/meteor';
4 | import { HTTP } from 'meteor/http';
5 | import { Random } from 'meteor/random';
6 | import MetaInspector from 'node-metainspector';
7 |
8 | export default function () {
9 | if (Meteor.isServer) {
10 | Notifications = require('../server/notifications.js');
11 | fs = require('fs');
12 | path = require('path');
13 | request = require('request');
14 | stream = require('stream');
15 | }
16 |
17 | Meteor.methods({
18 | "entry.create"({tags}) {
19 | if (!this.userId) { throw new Meteor.Error('not-authorized'); }
20 |
21 | const entryID = Entries.insert({
22 | createdAt: new Date(),
23 | updatedAt: new Date(),
24 | tags: tags
25 | });
26 |
27 | return entryID;
28 | },
29 |
30 | "entry.update"({entryID, newEntry}) {
31 | if (!this.userId) { throw new Meteor.Error('not-authorized'); }
32 |
33 | const filteredEntry = {
34 | title: newEntry.title,
35 | author: newEntry.author,
36 | tags: newEntry.tags,
37 | imageURL: newEntry.imageURL,
38 | description: newEntry.description,
39 | updatedAt: new Date(),
40 | };
41 |
42 | Entries.update(entryID, {$set: filteredEntry});
43 | },
44 |
45 | "entry.setImage"({entryID, imageURL}) {
46 | if (!this.userId) { throw new Meteor.Error('not-authorized'); }
47 |
48 | Entries.update(entryID, {$set: {
49 | imageURL,
50 | updatedAt: new Date(),
51 | }});
52 | },
53 |
54 | "entry.setURL"({entryID, URL}) {
55 | if (!this.userId) { throw new Meteor.Error('not-authorized'); }
56 |
57 | if (Meteor.isServer) {
58 | let client = new MetaInspector(URL, { timeout: 5000 });
59 | client.on("fetch", Meteor.bindEnvironment(() => {
60 | // OK, so what I'm doing here is not good. It's race-y. I know it's race-y. But I think it's gonna be fine anyway.
61 | const entry = Entries.findOne(entryID);
62 | if (entry) {
63 | let updates = {}
64 | if ((!entry.title || entry.title === "") && (client.title || client.ogTitle)) {
65 | updates.title = client.ogTitle || client.title;
66 | }
67 | if ((!entry.author || entry.author === "") && client.author) {
68 | updates.author = client.author;
69 | }
70 |
71 | if (Object.keys(updates).length > 0) {
72 | Meteor.call("entry.update", {entryID: entryID, newEntry: updates});
73 | }
74 |
75 | if ((!entry.imageURL || entry.imageURL === "") && client.image) {
76 | const extension = path.extname(path.basename(client.image));
77 | const outputPath = `/tmp/${Random.id()}${extension}`;
78 | const file = fs.createWriteStream(outputPath);
79 | console.log(`Getting ${client.image}`);
80 | request(client.image)
81 | .pipe(file)
82 | .on("finish", Meteor.bindEnvironment((err) => {
83 | console.log(`Downloaded to ${outputPath}`);
84 | if (err) {
85 | file.end();
86 | console.error(err);
87 | } else {
88 | S3.knox.putFile(outputPath, `/${S3.config.bucket}/${entryUploadPath}/${Meteor.uuid()}${extension}`, Meteor.bindEnvironment((err, res) => {
89 | if (res) {
90 | Meteor.call("entry.setImage", {entryID, imageURL: res.socket._httpMessage.url})
91 | } else {
92 | console.error(`Failed to upload ${client.image}: ${err}`);
93 | }
94 | }));
95 | }
96 | }));
97 | }
98 | }
99 | }));
100 | client.on("error", function(err){
101 | console.log(err);
102 | });
103 | client.fetch();
104 | }
105 |
106 | Entries.update(entryID, {$set: {URL: URL}});
107 | },
108 |
109 | "entry.remove"({entryID}) {
110 | if (!this.userId) { throw new Meteor.Error('not-authorized'); }
111 |
112 | Entries.remove(entryID);
113 | },
114 |
115 | "entry.updateRecommender"({entryID, isNewlyRecommending}) {
116 | if (!this.userId) { throw new Meteor.Error('not-authorized'); }
117 |
118 | if (isNewlyRecommending) {
119 | Entries.update(entryID, {$addToSet: {recommenders: this.userId}});
120 | } else {
121 | Entries.update(entryID, {$pull: {recommenders: this.userId}});
122 | }
123 | Entries.update(entryID, {$set: {updatedAt: new Date()}});
124 | },
125 |
126 | "entry.updateViewer"({entryID, isNewlyViewing}) {
127 | if (!this.userId) { throw new Meteor.Error('not-authorized'); }
128 |
129 | if (isNewlyViewing) {
130 | Entries.update(entryID, {$addToSet: {viewers: this.userId}});
131 | } else {
132 | Entries.update(entryID, {$pull: {viewers: this.userId}});
133 | }
134 | Entries.update(entryID, {$set: {updatedAt: new Date()}});
135 | },
136 |
137 | "entry.startDiscussionThread"({entryID}) {
138 | if (!this.userId) { throw new Meteor.Error('not-authorized'); }
139 |
140 | const entry = Entries.findOne(entryID);
141 | if (entry.mailingListID) {
142 | throw new Meteor.Error("Entries.methods.startDiscussionThread.alreadyStarted", "Discussion thread has already been started.");
143 | } else {
144 | if (Meteor.isServer) {
145 | Notifications.sendNewEntryEmail(entryID);
146 | }
147 | Entries.update(entryID, {$set: {
148 | mailingListID: entryID,
149 | updatedAt: new Date(),
150 | }});
151 | }
152 | },
153 |
154 | "entries.fetchAllTagEntriesSortedDescending"() {
155 | if (Meteor.isServer) {
156 | return Entries.aggregate([
157 | {$project: {tags: 1}},
158 | {$unwind: "$tags"},
159 | {$group: {
160 | _id: "$tags",
161 | count: { $sum: 1 },
162 | }},
163 | {$sort: {count: -1}},
164 | ]).map((entry) => {
165 | return {tag: entry._id, count: entry.count}
166 | });
167 | } else {
168 | return [];
169 | }
170 | },
171 | });
172 | }
173 |
--------------------------------------------------------------------------------
/imports/ui/components/Entry.jsx:
--------------------------------------------------------------------------------
1 | import Dropzone from 'react-dropzone';
2 | import { Link } from 'react-router';
3 | import React from 'react';
4 |
5 | import DescriptionEditor from './DescriptionEditor.jsx';
6 | import EntryImage from './EntryImage.jsx';
7 | import EntryTextField from './EntryTextField.jsx';
8 | import SourceLink from './SourceLink.jsx';
9 | import TagEditor from './TagEditor.jsx';
10 | import ToggleList from './ToggleList.jsx';
11 | import { relativeURLForEntryID } from '../../api/entries/entries.js';
12 |
13 | // Represents a single hivemind database entry--the full, editable view.
14 | export default class Entry extends React.Component {
15 | constructor(props) {
16 | super(props);
17 |
18 | this.onChangeDescription = (rawContentState) => {
19 | this.props.onChange({...this.props.entry, description: rawContentState});
20 | };
21 |
22 | this.onChangeTitle = (newTitle) => {
23 | this.props.onChange({...this.props.entry, title: newTitle});
24 | };
25 |
26 | this.onChangeAuthor = (newAuthor) => {
27 | this.props.onChange({...this.props.entry, author: newAuthor});
28 | };
29 |
30 | this.onChangeURL = (newURL) => {
31 | this.props.onChangeURL(newURL);
32 | };
33 |
34 | this.onChangeTags = (newTags) => {
35 | this.props.onChange({...this.props.entry, tags: newTags});
36 | };
37 |
38 | this.onDelete = (event) => {
39 | if (window.confirm("Are you sure you want to delete this entry? There is no undo.")) {
40 | this.props.onDelete();
41 | }
42 | event.preventDefault();
43 | };
44 | }
45 |
46 | shouldComponentUpdate(nextProps, nextState) {
47 | if (nextState !== this.state) {
48 | console.log(`Updating entry ${nextProps.entry.title} due to changed state`);
49 | return true;
50 | } else {
51 | return nextProps.entry.updatedAt.getTime() !== this.props.entry.updatedAt.getTime() ||
52 | nextProps.disabled !== this.props.disabled;
53 | }
54 | }
55 |
56 | render() {
57 | const hasValidImage = (this.props.entry.imageURL || "") !== "";
58 |
59 | let mailingListLink;
60 | if (this.props.entry.mailingListID) {
61 | const nameForMailingListLink = this.props.entry.mailingListID.replace(" ", "$20");
62 | const mailingListURL = `https://groups.google.com/a/khanacademy.org/forum/#!searchin/long-term-research-team/%5Bhivemind%5D$20${nameForMailingListLink}`;
63 | mailingListLink = Discussion Thread ;
64 | } else {
65 | if (this.props.disabled) {
66 | mailingListLink = null;
67 | } else {
68 | mailingListLink = {this.props.onStartDiscussionThread(); e.preventDefault()}}>Start Discussion Thread ;
69 | }
70 | }
71 |
72 | const bottomControls =
73 | ;
79 |
80 | const dates =
81 |
82 | Added on {this.props.entry.createdAt.toLocaleDateString("en-us", {year: "2-digit", month: "2-digit", day: "2-digit"})}.
83 | Updated on {this.props.entry.updatedAt.toLocaleDateString("en-us", {year: "2-digit", month: "2-digit", day: "2-digit"})}.
84 |
;
85 |
86 | const descriptionEditor =
87 | ;
92 |
93 | const recommenderList = ;
100 |
101 | const viewerList = ;
108 |
109 | const tagEditor =
110 | ;
116 |
117 | let contents;
118 | if (hasValidImage) {
119 | contents = (
120 |
121 |
122 |
123 |
127 |
128 | {recommenderList}
129 | {viewerList}
130 | {bottomControls}
131 |
132 |
133 | {descriptionEditor}
134 |
135 | {tagEditor}
136 | {dates}
137 |
138 |
139 |
140 | );
141 | } else {
142 | contents = (
143 |
151 |
152 |
153 | {descriptionEditor}
154 |
155 | {tagEditor}
156 | {dates}
157 |
158 |
159 | {recommenderList}
160 | {viewerList}
161 | {bottomControls}
162 |
163 |
164 |
165 |
166 | );
167 | }
168 |
169 | return (
170 |
171 |
195 | {contents}
196 |
197 | );
198 | }
199 | }
200 |
--------------------------------------------------------------------------------
/client/main.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: "Proxima Nova", Helvetica, sans-serif;
3 | margin-top: 3em;
4 | }
5 |
6 | a {
7 | text-decoration: none;
8 | }
9 |
10 | a:hover {
11 | text-decoration: underline;
12 | }
13 |
14 | ::-webkit-input-placeholder, .public-DraftEditorPlaceholder-root {
15 | color: #888D93;
16 | }
17 |
18 | ::placeholder {
19 | color: #888D93;
20 | }
21 |
22 | #pageContainer {
23 | max-width: 850px;
24 | margin: 0px auto;
25 | }
26 |
27 | #siteHeader {
28 | font-size: 35px;
29 | display: flex;
30 | flex-direction: row;
31 | align-items: baseline;
32 | margin-bottom: 1em;
33 | }
34 |
35 | #siteHeader h1 {
36 | margin: 0;
37 | font: inherit;
38 | font-weight: 600;
39 | flex: 0 0 auto;
40 | }
41 |
42 | #siteHeader .search {
43 | border: none;
44 | font-size: 27px;
45 | font-family: inherit;
46 | margin-left: 20px;
47 | flex: 1 0 auto;
48 | align-self: auto;
49 | }
50 |
51 | #siteHeader .search:hover {
52 | border: 0.5px solid #ECEDEE;
53 | border-radius: 4px;
54 | margin-left: 19px;
55 | margin-top: -1px;
56 | }
57 |
58 | #siteHeader a {
59 | color: black;
60 | }
61 |
62 | #siteHeader .add {
63 | font-weight: 600;
64 | margin-left: 27px;
65 | align-self: stretch;
66 | font-size: 40px;
67 | margin-top: -1px;
68 | }
69 |
70 | #siteHeader .add:hover {
71 | text-decoration: none;
72 | }
73 |
74 | #siteHeader .allEntries {
75 | color: #888D93;
76 | margin-left: 30px;
77 | font-size: 27px;
78 | flex: 1 0 auto;
79 | }
80 |
81 | #siteHeader .userButton {
82 | font-size: 27px;
83 | color: #888D93;
84 | align-self: auto;
85 | margin-left: 20px;
86 | }
87 |
88 | .entryList {
89 | width: 760px;
90 | margin: 0 auto;
91 | }
92 |
93 | .entryCell {
94 | margin: 2em 0em;
95 | display: flex;
96 | flex-direction: row;
97 | }
98 |
99 | .entryCell:hover {
100 | text-decoration: none;
101 | background-color: #F8F8F8;
102 | cursor: pointer;
103 | margin: -0.5em;
104 | padding: 0.5em;
105 | }
106 |
107 | .entryCell .content {
108 | flex-grow: 1;
109 | margin-left: 1.5em;
110 | min-width: 0; /* Allow truncation past intrinsic content width. */
111 | font-size: 15px;
112 | line-height: 18px;
113 | }
114 |
115 | .entryCell .heading {
116 | display: flex;
117 | flex-direction: row;
118 | margin-bottom: 3px;
119 | }
120 |
121 | .entryCell .heading .title {
122 | font-weight: 600;
123 | color: #21242C;
124 | display: block;
125 | /*margin-bottom: 3px;*/ /* Not sure... */
126 | }
127 |
128 | .entryCell .heading .author {
129 | display: block;
130 | }
131 |
132 | .entryCell .toggleLists {
133 | flex-shrink: 0;
134 | }
135 |
136 | .entryCell .imageContainer {
137 | flex-shrink: 0;
138 | width: 100px;
139 | text-align: right;
140 | line-height: 0;
141 | }
142 |
143 | .entryCell .imageContainer img {
144 | max-width: 100%;
145 | max-height: 75px;
146 | }
147 |
148 | .entryCell .imageContainer .imagePlaceholder {
149 | width: 100%;
150 | height: 75px;
151 | display: inline-block;
152 | }
153 |
154 | .entryCell .imageContainer img, .entryCell .imageContainer .imagePlaceholder {
155 | border: 1px solid rgba(0, 0, 0, 0.1);
156 | border-radius: 4px;
157 | }
158 |
159 | .entryCell .titleAndAuthor {
160 | flex-grow: 1;
161 | }
162 |
163 | .entryCell .titleAndAuthor.pending {
164 | font-style: italic;
165 | color: #757575
166 | }
167 |
168 | .entryCell .description {
169 | font-size: 14px;
170 | line-height: 18px;
171 | color: #757575;
172 | overflow: hidden;
173 | -webkit-line-clamp: 2;
174 | display: -webkit-box;
175 | -webkit-box-orient: vertical;
176 | width: 550px;
177 | }
178 |
179 | .entryCell .userToggleList {
180 | height: 18px;
181 | display: flex;
182 | flex-shrink: 0;
183 | margin-left: 0.5em;
184 | margin-bottom: 2px;
185 | }
186 |
187 | .entryCell .userToggleList img {
188 | height: 18px;
189 | }
190 |
191 | .lightbox {
192 | position: fixed;
193 | top: 0;
194 | left: 0;
195 | width: 100%;
196 | height: 100%;
197 | background-color: rgba(79, 82, 85, 0.6);
198 | display: flex;
199 | flex-direction: column;
200 | justify-content: space-around;
201 | }
202 |
203 | .lightbox .entry {
204 | position: relative;
205 | padding: 30px;
206 | min-width: 700px;
207 | max-width: 960px;
208 | height: 80vh;
209 | margin: 0 auto;
210 | background-color: white;
211 | overflow-y: scroll;
212 | border-radius: 4px;
213 | }
214 |
215 | .entry {
216 | margin: 2.5em 0;
217 | padding-bottom: 2.5em;
218 | border-bottom: 0.5px solid #ECEDEE;
219 | }
220 |
221 | .entry:last-child {
222 | border-bottom: initial;
223 | }
224 |
225 | .entry header {
226 | position: relative;
227 | margin-bottom: 1.5em;
228 | }
229 |
230 | .entry .titleAndAuthor {
231 | margin-right: 13em;
232 | }
233 |
234 | .entry header .title, .entry header .author {
235 | display: inline-block;
236 | }
237 |
238 | .entry header .title {
239 | font-size: 23px;
240 | font-weight: 600;
241 | max-width: 100%;
242 | }
243 |
244 | .entry header .author {
245 | font-size: 20px;
246 | color: #626469;
247 | }
248 |
249 | .entry header .author.hidden-until-hover {
250 | opacity: 0;
251 | position: absolute;
252 | }
253 |
254 | .entry.editable:hover header .author.hidden-until-hover {
255 | opacity: inherit;
256 | position: static;
257 | }
258 |
259 | .entry header .externalLink {
260 | position: absolute;
261 | right: 0px;
262 | top: 5px;
263 | text-align: right;
264 | font-size: 17px;
265 | width: 13em;
266 | }
267 |
268 | .entry header .externalLink a {
269 | color: #888D93;
270 | }
271 |
272 | .entry header .externalLink .edit {
273 | display: none;
274 | }
275 |
276 | .entry:hover header .externalLink .edit {
277 | color: #D6D8DA;
278 | bottom: 0;
279 | display: inline-block;
280 | }
281 |
282 | .entry header input {
283 | border: 0.5px solid rgba(0,0,0,0);
284 | border-radius: 4px;
285 | margin: -2.5px;
286 | padding: 3px;
287 | font: inherit;
288 | color: inherit;
289 | max-width: 100%;
290 | }
291 |
292 | .entry.editable header:hover input, .entry.editable header input:focus {
293 | border-color: #ECEDEE;
294 | }
295 |
296 | .entry .contents {
297 | display: flex;
298 | flex-flow: row;
299 | }
300 |
301 | .entry .leftColumn {
302 | flex: 0 0 240px;
303 | display: flex;
304 | flex-flow: column;
305 | }
306 |
307 | .entry .leftColumn .bottomControls {
308 | flex: 0 0 auto;
309 | display: flex;
310 | align-items: flex-end;
311 | justify-content: flex-start;
312 | margin-top: auto;
313 | flex-direction: row;
314 | flex-wrap: wrap;
315 | }
316 |
317 | .entry .leftColumn .bottomControls a {
318 | flex: 0 0 auto;
319 | }
320 |
321 | .entry .bottomControls a {
322 | margin-right: 1em;
323 | opacity: 0;
324 | color: #D6D8DA;
325 | }
326 |
327 | .entry:hover .bottomControls a {
328 | opacity: 1
329 | }
330 |
331 | .entry .bottomControls a:last-child {
332 | margin-right: 0;
333 | }
334 |
335 | .entry .leftColumn .entryImage img {
336 | width: 100%;
337 | margin-bottom: 0.5em;
338 | }
339 |
340 | .entry .bottomControls {
341 | font-size: 15px;
342 | margin-top: 1em;
343 | }
344 |
345 | .entry .bottomControls .startDiscussionThread {
346 | color: #199CC1;
347 | }
348 |
349 | .entry .dates {
350 | margin-top: 1em;
351 | align-self: flex-end;
352 | margin-bottom: 0.5em;
353 | }
354 |
355 | .entry .dates span {
356 | font-size: 15px;
357 | color: #D6D8DA;
358 | display: inline-block;
359 | }
360 |
361 | .entry .dates span:first-child {
362 | margin-right: 0.5em;
363 | }
364 |
365 | .userToggleList {
366 | height: 24px;
367 | display: flex;
368 | flex-direction: row;
369 | align-items: center;
370 | }
371 |
372 | .userToggleList img {
373 | height: 24px;
374 | margin-right: 7px;
375 | }
376 |
377 | .userToggleList a {
378 | line-height: 0;
379 | }
380 |
381 | .userToggleList span {
382 | display: inline-block;
383 | margin-right: 7px;
384 | white-space: nowrap;
385 | }
386 |
387 | .userToggleList .noActiveUsers {
388 | color: #ECEDEE;
389 | }
390 |
391 | .entry .tagEditorAndDates {
392 | display: flex;
393 | flex-direction: row;
394 | }
395 |
396 | .entry .tagEditorAndDates .tagEditor {
397 | flex: 1 1 auto;
398 | }
399 |
400 | .entry .tagEditorAndDates .dates {
401 | margin-top: 0;
402 | flex: 0 0 auto;
403 | }
404 |
405 | .entry .toggleListsAndControls {
406 | display: flex;
407 | flex-direction: row;
408 | margin-top: 0.5em;
409 | }
410 |
411 | .entry .toggleListsAndControls .userToggleList {
412 | flex: 0 0 auto;
413 | margin-right: 0.5em;
414 | }
415 |
416 | .entry .toggleListsAndControls .bottomControls {
417 | margin-top: 0;
418 | flex: 1 0 auto;
419 | text-align: right;
420 | }
421 |
422 | .entry .notes {
423 | margin-left: 1.5em;
424 | flex: 1 1 auto;
425 | }
426 |
427 | .entry .descriptionEditor {
428 | font-size: 20px;
429 | line-height: 30px;
430 | margin-bottom: 30px;
431 | }
432 |
433 | .entry .descriptionEditor a {
434 | color: #199CC1;
435 | }
436 |
437 | .entry.editable .DraftEditor-root:hover {
438 |
439 | border: 0.5px solid #ECEDEE;
440 | border-radius: 4px;
441 | padding: 4px;
442 | margin: -4.5px;
443 | }
444 |
445 | .entry .oneColumn .descriptionEditor {
446 | max-width: 700px;
447 | }
448 |
449 | .entry .tagEditor.react-selectize.default {
450 | font-size: 17px;
451 | margin-right: 1.5em;
452 | font-family: inherit;
453 | border-color: #ECEDEE;
454 | }
455 |
456 | .entry .tagEditor a {
457 | color: #888D93;
458 | }
459 |
460 | .entry .react-selectize.default.tagEditor.root-node .react-selectize-control {
461 | border: none;
462 | cursor: text;
463 | font-size: inherit;
464 | border-bottom: 0.5px solid rgba(0,0,0,0);
465 | }
466 |
467 | .entry .react-selectize.default.tagEditor.root-node .react-selectize-control:hover {
468 | border-color: #ECEDEE;
469 | }
470 |
471 | .entry .react-selectize.default.tagEditor.root-node .react-selectize-control .react-selectize-placeholder {
472 | font-family: inherit;
473 | font-size: inherit;
474 | text-indent: 0px;
475 | }
476 |
477 | .entry .react-selectize.default.tagEditor.root-node .react-selectize-control .react-selectize-search-field-and-selected-values {
478 | padding-left: 0px;
479 | }
480 |
481 | .entry .tagEditor .react-selectize.dropdown-menu.default {
482 | border-radius: 4px;
483 | max-height: 300px;
484 | }
485 |
486 | .entry .tagEditor .react-selectize.dropdown-menu.default .simple-option, .entry .tagEditor .react-selectize.dropdown-menu.default .no-results-found {
487 | font-size: 15px;
488 | padding: 4px 10px;
489 | }
490 |
491 | .tagBlock {
492 | display: inline;
493 | padding-right: 1.0em;
494 | }
495 |
496 | .unconfirmedTagBlock {
497 | display: inline;
498 | }
499 |
500 | .unconfirmedTagBlock br {
501 | display: none;
502 | }
503 |
504 | .unconfirmedTagBlock:only-child {
505 | display: inherit;
506 | }
507 |
508 | .unconfirmedTagBlock:only-child br {
509 | display: inherit;
510 | }
511 |
512 | .unconfirmedTagBlock .public-DraftStyleDefault-block {
513 | display: inline;
514 | }
515 |
516 | .entry .activeImageDrop {
517 | border: 3px black solid;
518 | }
519 |
--------------------------------------------------------------------------------