This commit is contained in:
Rommel Manalo 2017-04-07 11:30:10 +08:00
commit 6d658b3738
116 changed files with 966 additions and 1108 deletions

View file

@ -1,38 +1,11 @@
# see http://docs.vulcanjs.org/packages
############ Core Packages ############
vulcan:core # core components and wrappers
vulcan:forms # auto-generated forms
vulcan:routing # routing and server-side rendering
vulcan:users # user management and permissions
############ Features Packages ############
# vulcan:email
# vulcan:posts
# vulcan:comments
# vulcan:newsletter
# vulcan:notifications
# vulcan:getting-started
# vulcan:categories
# vulcan:voting
# vulcan:events
# vulcan:embedly
# vulcan:api
# vulcan:rss
# vulcan:subscribe
############ Theme Packages ############
# vulcan:base-components # default ui components
# vulcan:base-styles # default styling
# vulcan:email-templates # default email templates for notifications
vulcan:core
############ Language Packages ############
vulcan:i18n-en-us # default language translation
vulcan:i18n-en-us
############ Accounts Packages ############
@ -43,5 +16,6 @@ accounts-password@1.3.4
############ Your Packages ############
example-movies
# example-movies-full
# example-instagram
# example-forum
# example-customization

View file

@ -1,6 +1,5 @@
accounts-base@1.2.15
accounts-password@1.3.4
aldeed:collection2-core@2.0.0
allow-deny@1.0.5
autoupdate@1.3.12
babel-compiler@6.14.1
@ -25,16 +24,13 @@ ecmascript-runtime@0.3.15
ejson@1.0.13
email@1.1.18
example-movies@0.0.0
fourseven:scss@3.8.1
fourseven:scss@4.5.0
geojson-utils@1.0.10
hot-code-push@1.0.4
html-tools@1.0.11
htmljs@1.0.11
http@1.2.12
id-map@1.0.9
jparker:crypto-core@0.1.0
jparker:crypto-md5@0.1.1
jparker:gravatar@0.5.1
jquery@1.11.10
livedata@1.0.18
localstorage@1.0.12
@ -56,7 +52,6 @@ observe-sequence@1.0.16
ordered-dict@1.0.9
percolatestudio:synced-cron@1.1.0
promise@0.8.8
raix:eventemitter@0.1.3
random@1.0.10
rate-limit@1.0.7
reactive-dict@1.1.8
@ -74,7 +69,6 @@ srp@1.0.10
standard-minifier-css@1.3.4
standard-minifier-js@1.2.3
standard-minifiers@1.0.6
tmeasday:check-npm-versions@0.3.1
tracker@1.1.2
ui@1.0.12
underscore@1.0.10

View file

@ -11,12 +11,13 @@
},
"dependencies": {
"analytics-node": "^2.1.1",
"apollo-client": "^1.0.0-rc.5",
"apollo-client": "^1.0.1",
"babel-runtime": "^6.18.0",
"bcrypt": "^0.8.7",
"body-parser": "^1.15.2",
"classnames": "^2.2.3",
"cookie-parser": "^1.4.3",
"crypto-js": "^3.1.9-1",
"deepmerge": "^1.2.0",
"escape-string-regexp": "^1.0.5",
"express": "^4.14.0",
@ -26,12 +27,11 @@
"graphql-anywhere": "^3.0.1",
"graphql-date": "^1.0.2",
"graphql-server-express": "^0.6.0",
"graphql-tag": "^1.3.2",
"graphql-tag": "^2.0.0",
"graphql-tools": "^0.10.1",
"graphql-type-json": "^0.1.4",
"handlebars": "^4.0.5",
"history": "^3.0.0",
"hoist-non-react-statics": "^1.2.0",
"html-to-text": "^2.1.0",
"immutability-helper": "^2.0.0",
"intl": "^1.2.4",
@ -52,6 +52,7 @@
"react-cookie": "^0.4.6",
"react-datetime": "^2.3.2",
"react-dom": "^15.4.1",
"react-dropzone": "^3.12.2",
"react-helmet": "^3.1.0",
"react-intl": "^2.1.3",
"react-redux": "^5.0.1",

View file

@ -3,12 +3,12 @@ Let's import all our files here.
*/
// general business logic of this customization
import "./callbacks.js"
import "./emails.js"
import "./custom_fields.js"
import "./i18n.js"
import "./groups.js"
import "./fragments.js"
import "./callbacks.js";
import "./emails.js";
import "./custom_fields.js";
import "./i18n.js";
import "./groups.js";
import "./fragments.js";
// custom components
import "./components/CustomLogo.jsx";

View file

@ -5,29 +5,19 @@ Package.describe({
Package.onUse( function(api) {
api.use([
'fourseven:scss',
'vulcan:core',
'vulcan:base-components',
'vulcan:posts',
'vulcan:users'
'example-forum',
'fourseven:scss@4.5.0',
]);
api.mainModule('server.js', 'server');
api.mainModule('client.js', 'client');
api.addFiles([
'lib/modules.js'
], ['client', 'server']);
api.addFiles([
'lib/stylesheets/custom.scss'
], ['client']);
api.addFiles([
'lib/server/templates.js'
], ['server']);
api.addAssets([
'lib/server/emails/customNewPost.handlebars',
'lib/server/emails/customEmail.handlebars'

View file

@ -0,0 +1 @@
Vulcan forum example package.

View file

@ -0,0 +1,43 @@
Package.describe({
name: "example-forum",
summary: "Telescope forum package",
version: '1.3.0',
git: "https://github.com/TelescopeJS/Telescope.git"
});
Package.onUse(function (api) {
api.versionsFrom(['METEOR@1.0']);
api.use([
// vulcan core
'vulcan:core@1.3.0',
// vulcan packages
'vulcan:posts@1.3.0',
'vulcan:comments@1.3.0',
'vulcan:voting@1.3.0',
'vulcan:accounts@1.3.0',
'vulcan:email',
'vulcan:forms',
'vulcan:newsletter',
'vulcan:notifications',
'vulcan:getting-started',
'vulcan:categories',
'vulcan:events',
'vulcan:embedly',
'vulcan:api',
'vulcan:rss',
'vulcan:subscribe',
'vulcan:base-components',
'vulcan:base-styles',
'vulcan:email-templates',
]);
// api.mainModule("lib/server.js", "server");
// api.mainModule("lib/client.js", "client");
});

View file

@ -1,7 +1,17 @@
/*
A component to configure the "edit comment" form.
Wrapped with the "withDocument" container.
Components.SmartForm props:
- collection: the collection in which to edit a document
- documentId: the id of the document to edit
- mutationFragment: the GraphQL fragment defining the data returned by the mutation
- showRemove: whether to show the "delete document" action in the form
- successCallback: what to do after the mutation succeeds
Note: `closeModal` is available as a prop because this form will be opened
in a modal popup.
*/

View file

@ -1,7 +1,11 @@
/*
An item in the comments list.
Wrapped with the "withCurrentUser" container.
Note: Comments.options.mutations.edit.check is defined in
modules/comments/mutations.js and is used both on the server when
performing the mutation, and here to check if the form link
should be displayed.
*/

View file

@ -3,6 +3,8 @@
List of comments.
Wrapped with the "withList" and "withCurrentUser" containers.
All props except currentUser are passed by the withList container.
*/
import React, { PropTypes, Component } from 'react';

View file

@ -2,14 +2,19 @@
A component to configure the "new comment" form.
The "prefilledProps" option lets you prefill specific form fields
(in this case "picId"). This works even if the field is not actually
displayed in the form, as is the case here
(picId's "hidden" property is set to true in the Comments schema)
*/
import React, { PropTypes, Component } from 'react';
import { Components, registerComponent, withCurrentUser, getFragment } from 'meteor/vulcan:core';
import { Components, registerComponent, getFragment } from 'meteor/vulcan:core';
import Comments from '../../modules/comments/collection.js';
const CommentsNewForm = ({currentUser, picId}) =>
const CommentsNewForm = ({picId}) =>
<div className="comments-new-form">
@ -21,4 +26,4 @@ const CommentsNewForm = ({currentUser, picId}) =>
</div>
export default withCurrentUser(CommentsNewForm);
export default CommentsNewForm;

View file

@ -1,9 +1,21 @@
/*
The Header component.
Components.ModalTrigger is a built-in Vulcan component that displays
its children in a popup triggered by either a text link, or a cusotm
component (if the "component" prop is specified).
*/
import React, { PropTypes, Component } from 'react';
import { Components, withCurrentUser } from 'meteor/vulcan:core';
import Users from 'meteor/vulcan:users';
import PicsNewForm from '../pics/PicsNewForm';
const HeaderLoggedIn = ({currentUser}) =>
// navigation bar component when the user is logged in
const NavLoggedIn = ({currentUser}) =>
<div className="header-nav header-logged-in">
@ -20,13 +32,15 @@ const HeaderLoggedIn = ({currentUser}) =>
</div>
<Components.ModalTrigger label="Upload" size="small">
<Components.ModalTrigger label="Upload">
<PicsNewForm />
</Components.ModalTrigger>
</div>
const HeaderLoggedOut = ({currentUser}) =>
// navigation bar component when the user is logged out
const NavLoggedOut = ({currentUser}) =>
<div className="header-nav header-logged-out">
@ -36,6 +50,8 @@ const HeaderLoggedOut = ({currentUser}) =>
</div>
// Header component
const Header = ({currentUser}) =>
<div className="header-wrapper">
@ -47,8 +63,8 @@ const Header = ({currentUser}) =>
</h1>
{currentUser ?
<HeaderLoggedIn currentUser={currentUser}/> :
<HeaderLoggedOut currentUser={currentUser}/>
<NavLoggedIn currentUser={currentUser}/> :
<NavLoggedOut currentUser={currentUser}/>
}
</div>

View file

@ -1,11 +1,40 @@
/*
The Layout component.
In other words, the template used to display every page in the app.
Specific pages will be displayed in place of the "children" property.
Note: the Helmet library is used to insert meta tags and link tags in the <head>
*/
import React, { PropTypes, Component } from 'react';
import Helmet from 'react-helmet';
import Header from './Header.jsx';
const links = [
// note: modal popups won't work with anything above alpha.5.
// see https://github.com/twbs/bootstrap/issues/21876#issuecomment-276181539
{
rel: 'stylesheet',
type: 'text/css',
href: 'https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-alpha.5/css/bootstrap.min.css'
},
{
rel: 'stylesheet',
type: 'text/css',
href: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css'
}
];
const Layout = ({children}) =>
<div className="wrapper" id="wrapper">
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css" />
<Helmet title="Vulcanstagram" link={links} />
<link />
<Header/>

View file

@ -1,6 +1,6 @@
/*
A component that shows a detailed view of a single movie.
A component that shows a detailed view of a single picture.
Wrapped with the "withDocument" container.
*/
@ -25,7 +25,7 @@ const PicsDetails = ({loading, document, currentUser}) => {
<div className="pics-details">
<div className="pics-details-image"><img src={`http://vulcanjs.org/photos/${document.imageUrl}`}/></div>
<div className="pics-details-image"><img src={document.imageUrl}/></div>
<div className="pics-details-sidebar">

View file

@ -1,7 +1,6 @@
/*
A component to configure the "edit pic" form.
Wrapped with the "withDocument" container.
*/
@ -15,7 +14,7 @@ const PicsEditForm = ({documentId, closeModal}) =>
<Components.SmartForm
collection={Pics}
documentId={documentId}
mutationFragment={getFragment('PicsItemFragment')}
mutationFragment={getFragment('PicsDetailsFragment')}
showRemove={true}
successCallback={document => {
closeModal();

View file

@ -1,11 +0,0 @@
import React, { PropTypes, Component } from 'react';
const PicsImage = ({imageUrl, onClick}) =>
<div className="pics-image" onClick={onClick}>
<img src={`http://vulcanjs.org/photos/${imageUrl}`}/>
</div>
export default PicsImage;

View file

@ -1,7 +1,6 @@
/*
An item in the pics list.
Wrapped with the "withCurrentUser" container.
*/
@ -9,15 +8,12 @@ import React, { PropTypes, Component } from 'react';
import { Components, registerComponent } from 'meteor/vulcan:core';
import PicsDetail from './PicsDetails.jsx';
import PicsImage from './PicsImage.jsx';
const PicsItem = ({pic, currentUser}) =>
<div className="pics-item">
{/* document properties */}
<Components.ModalTrigger className="pics-details-modal" component={<PicsImage imageUrl={pic.imageUrl} />}>
<Components.ModalTrigger className="pics-details-modal" component={<div className="pics-image"><img src={pic.imageUrl}/></div>}>
<PicsDetail documentId={pic._id} currentUser={currentUser} />
</Components.ModalTrigger>

View file

@ -2,6 +2,9 @@
A component to configure the "new pic" form.
We're using Pics.options.mutations.new.check (defined in modules/pics/mutations.js)
to check if the user has the proper permissions to actually insert a new picture.
*/
import React, { PropTypes, Component } from 'react';
@ -9,18 +12,16 @@ import { Components, registerComponent, withCurrentUser, getFragment } from 'met
import Pics from '../../modules/pics/collection.js';
const PicsNewForm = ({currentUser}) =>
const PicsNewForm = ({currentUser, closeModal}) =>
<div>
{Pics.options.mutations.new.check(currentUser) ?
<div style={{marginBottom: '20px', paddingBottom: '20px', borderBottom: '1px solid #ccc'}}>
<h4>Insert New Document</h4>
<Components.SmartForm
collection={Pics}
mutationFragment={getFragment('PicsItemFragment')}
/>
</div> :
<Components.SmartForm
collection={Pics}
mutationFragment={getFragment('PicsItemFragment')}
successCallback={closeModal}
/> :
null
}

View file

@ -10,11 +10,13 @@ import resolvers from './resolvers.js';
import './fragments.js';
import mutations from './mutations.js';
import './permissions.js';
import './parameters.js';
const Comments = createCollection({
collectionName: 'comments',
collectionName: 'Comments',
// avoid conflicts with 'comments' collection in vulcan:comments
dbCollectionName: 'commentsInstagram',
typeName: 'Comment',
@ -26,7 +28,16 @@ const Comments = createCollection({
});
Comments.addView('picComments', function (terms) {
/*
Set a default results view whenever the Comments collection is queried:
- Comments are limited to those corresponding to the current picture
- They're sorted by their createdAt timestamp in ascending order
*/
Comments.addDefaultView(terms => {
return {
selector: {picId: terms.picId},
options: {sort: {createdAt: 1}}

View file

@ -1,10 +0,0 @@
// import { addCallback } from 'meteor/vulcan:core';
// function sortByCreatedAt (parameters, terms) {
// return {
// selector: parameters.selector,
// options: {...parameters.options, sort: {createdAt: 1}}
// };
// }
// addCallback('comments.parameters', sortByCreatedAt);

View file

@ -1,3 +1,9 @@
/*
Declare permissions for the comments collection.
*/
import Users from 'meteor/vulcan:users';
const membersActions = [

View file

@ -16,13 +16,14 @@ const schema = {
type: Date,
viewableBy: ['guests'],
autoValue: (documentOrModifier) => {
if (documentOrModifier && !documentOrModifier.$set) return new Date() // if this is an insert, set createdAt to current timestamp
// if this is an insert, set createdAt to current timestamp
if (documentOrModifier && !documentOrModifier.$set) return new Date()
}
},
userId: {
type: String,
viewableBy: ['guests'],
resolveAs: 'user: User',
resolveAs: 'user: User', // resolve as "user" on the client
},
// custom properties
@ -40,7 +41,7 @@ const schema = {
type: String,
viewableBy: ['guests'],
insertableBy: ['members'],
hidden: true,
hidden: true, // never show this in forms
},
};

View file

@ -1,3 +0,0 @@
import { Utils } from 'meteor/vulcan:core';
Utils.icons.comment = 'comment';

View file

@ -1,9 +1,10 @@
import { replaceComponent } from 'meteor/vulcan:core';
import { replaceComponent, Utils } from 'meteor/vulcan:core';
import './pics/collection.js';
import './comments/collection.js';
import Layout from '../components/common/Layout.jsx';
replaceComponent('Layout', Layout);
Utils.icons.comment = 'comment';
import './routes.js';
import './icons.js';

View file

@ -10,11 +10,10 @@ import resolvers from './resolvers.js';
import './fragments.js';
import mutations from './mutations.js';
import './permissions.js';
import './parameters.js';
const Pics = createCollection({
collectionName: 'pics',
collectionName: 'Pics',
typeName: 'Pic',
@ -26,4 +25,18 @@ const Pics = createCollection({
});
/*
Set a default results view whenever the Pics collection is queried:
- Pics are sorted by their createdAt timestamp in descending order
*/
Pics.addDefaultView(terms => {
return {
options: {sort: {createdAt: -1}}
};
});
export default Pics;

View file

@ -1,10 +0,0 @@
import { addCallback } from 'meteor/vulcan:core';
function sortByCreatedAt (parameters, terms) {
return {
selector: parameters.selector,
options: {...parameters.options, sort: {createdAt: -1}}
};
}
addCallback('pics.parameters', sortByCreatedAt);

View file

@ -1,3 +1,9 @@
/*
Declare permissions for the comments collection.
*/
import Users from 'meteor/vulcan:users';
const membersActions = [

View file

@ -4,6 +4,8 @@ A SimpleSchema-compatible JSON schema
*/
import FormsUpload from 'meteor/vulcan:forms-upload';
const schema = {
// default properties
@ -16,13 +18,14 @@ const schema = {
type: Date,
viewableBy: ['guests'],
autoValue: (documentOrModifier) => {
if (documentOrModifier && !documentOrModifier.$set) return new Date() // if this is an insert, set createdAt to current timestamp
// if this is an insert, set createdAt to current timestamp
if (documentOrModifier && !documentOrModifier.$set) return new Date()
}
},
userId: {
type: String,
viewableBy: ['guests'],
resolveAs: 'user: User',
resolveAs: 'user: User', // resolve this field as "user" on the client
},
// custom properties
@ -34,12 +37,18 @@ const schema = {
viewableBy: ['guests'],
insertableBy: ['members'],
editableBy: ['members'],
control: FormsUpload, // use the FormsUpload form component
form: {
options: {
preset: 'vulcanstagram'
},
}
},
body: {
label: 'Body',
type: String,
optional: true,
control: 'textarea',
control: 'textarea', // use a textarea form component
viewableBy: ['guests'],
insertableBy: ['members'],
editableBy: ['members']
@ -52,7 +61,7 @@ const schema = {
optional: true,
viewableBy: ['guests'],
hidden: true,
resolveAs: 'commentsCount: Float'
resolveAs: 'commentsCount: Float' // resolve as commentCount on the client
}
};

View file

@ -29,7 +29,7 @@ var createPic = function (imageUrl, createdAt, body, username) {
const pic = {
createdAt,
imageUrl,
imageUrl: `http://vulcanjs.org/photos/${imageUrl}`,
body,
isDummy: true,
userId: user._id

File diff suppressed because one or more lines are too long

View file

@ -5,15 +5,20 @@ Package.describe({
Package.onUse(function (api) {
api.use([
'vulcan:core',
'vulcan:forms',
'vulcan:routing',
'vulcan:accounts',
'fourseven:scss',
// vulcan core
'vulcan:core',
// vulcan packages
'vulcan:forms',
'vulcan:accounts',
'vulcan:forms-upload',
// third-party packages
'fourseven:scss@4.5.0',
]);
api.addFiles('lib/stylesheets/bootstrap.min.css');
api.addFiles('lib/stylesheets/style.scss');
api.addAssets([

View file

@ -1 +0,0 @@
Vulcan demo package.

View file

@ -1 +0,0 @@
import '../modules/index.js';

View file

@ -1,34 +0,0 @@
/*
A component that shows a detailed view of a single movie.
Wrapped with the "withDocument" container.
*/
import React, { PropTypes, Component } from 'react';
import Movies from '../../modules/movies/collection.js';
import { withDocument, registerComponent } from 'meteor/vulcan:core';
const MoviesDetails = props => {
const movie = props.document;
if (props.loading) {
return <p>Loading</p>
} else {
return (
<div>
<h2>{movie.name} ({movie.year})</h2>
<p>Reviewed by <strong>{movie.user && movie.user.displayName}</strong> on {movie.createdAt}</p>
<p>{movie.review}</p>
{movie.privateComments ? <p><strong>PRIVATE</strong>: {movie.privateComments}</p>: null}
</div>
)
}
}
const options = {
collection: Movies,
queryName: 'moviesSingleQuery',
fragmentName: 'MoviesDetailsFragment',
};
registerComponent('MoviesDetails', MoviesDetails, withDocument(options));

View file

@ -1,23 +0,0 @@
/*
A component to configure the "edit movie" form.
Wrapped with the "withDocument" container.
*/
import React, { PropTypes, Component } from 'react';
import { Components, registerComponent, getFragment } from "meteor/vulcan:core";
import Movies from '../../modules/movies/collection.js';
const MoviesEditForm = props =>
<Components.SmartForm
collection={Movies}
documentId={props.documentId}
mutationFragment={getFragment('MoviesDetailsFragment')}
showRemove={true}
successCallback={document => {
props.closeModal();
}}
/>
registerComponent('MoviesEditForm', MoviesEditForm);

View file

@ -1,61 +0,0 @@
/*
An item in the movies list.
Wrapped with the "withCurrentUser" container.
*/
import React, { PropTypes, Component } from 'react';
import { Button } from 'react-bootstrap';
import { Components, registerComponent, ModalTrigger } from 'meteor/vulcan:core';
import Movies from '../../modules/movies/collection.js';
class MoviesItem extends Component {
renderDetails() {
const movie = this.props.movie;
return (
<div style={{display: 'inline-block', marginRight: '5px'}}>
<ModalTrigger label="View Details">
<Components.MoviesDetails documentId={movie._id}/>
</ModalTrigger>
</div>
)
}
renderEdit() {
const movie = this.props.movie;
return (
<div style={{display: 'inline-block', marginRight: '5px'}}>
<ModalTrigger label="Edit Movie" >
<Components.MoviesEditForm currentUser={this.props.currentUser} documentId={movie._id} />
</ModalTrigger>
</div>
)
}
render() {
const movie = this.props.movie;
return (
<div key={movie.name} style={{paddingBottom: "15px",marginBottom: "15px", borderBottom: "1px solid #ccc"}}>
<h2>{movie.name} ({movie.year})</h2>
<p>By <strong>{movie.user && movie.user.displayName}</strong></p>
<div className="item-actions">
{this.renderDetails()}
&nbsp;|&nbsp;
{Movies.options.mutations.edit.check(this.props.currentUser, movie) ? this.renderEdit() : null}
</div>
</div>
)
}
}
registerComponent('MoviesItem', MoviesItem);

View file

@ -1,61 +0,0 @@
/*
List of movies.
Wrapped with the "withList" and "withCurrentUser" containers.
*/
import React, { PropTypes, Component } from 'react';
import { Button } from 'react-bootstrap';
import Movies from '../../modules/movies/collection.js';
import { Components, registerComponent, ModalTrigger, withList, withCurrentUser } from 'meteor/vulcan:core';
const LoadMore = props => <a href="#" className="load-more button button--primary" onClick={e => {e.preventDefault(); props.loadMore();}}>Load More ({props.count}/{props.totalCount})</a>
class MoviesList extends Component {
renderNew() {
const component = (
<div className="add-movie">
<ModalTrigger
title="Add Movie"
component={<Button bsStyle="primary">Add Movie</Button>}
>
<Components.MoviesNewForm />
</ModalTrigger>
<hr/>
</div>
)
return !!this.props.currentUser ? component : null;
}
render() {
const canCreateNewMovie = Movies.options.mutations.new.check(this.props.currentUser);
if (this.props.loading) {
return <Components.Loading />
} else {
const hasMore = this.props.totalCount > this.props.results.length;
return (
<div className="movies">
{canCreateNewMovie ? this.renderNew() : null}
{this.props.results.map(movie => <Components.MoviesItem key={movie._id} movie={movie} currentUser={this.props.currentUser} />)}
{hasMore ? <LoadMore {...this.props}/> : <p>No more movies</p>}
</div>
)
}
}
}
const options = {
collection: Movies,
queryName: 'moviesListQuery',
fragmentName: 'MoviesItemFragment',
limit: 5,
};
registerComponent('MoviesList', MoviesList, withList(options), withCurrentUser);

View file

@ -1,20 +0,0 @@
/*
A component to configure the "new movie" form.
*/
import React, { PropTypes, Component } from 'react';
import Movies from '../../modules/movies/collection.js';
import { Components, registerComponent, withMessages, getFragment } from 'meteor/vulcan:core';
const MoviesNewForm = props =>
<Components.SmartForm
collection={Movies}
mutationFragment={getFragment('MoviesItemFragment')}
successCallback={document => {
props.closeModal();
}}
/>
registerComponent('MoviesNewForm', MoviesNewForm, withMessages);

View file

@ -1,23 +0,0 @@
/*
Wrapper for the Movies components
*/
import React, { PropTypes, Component } from 'react';
import { Components, registerComponent } from 'meteor/vulcan:core';
const MoviesWrapper = () =>
<div className="wrapper framework-demo" style={{maxWidth: '500px', margin: 'auto'}}>
<div className="header" style={{padding: '20px 0', marginBottom: '20px', borderBottom: '1px solid #ccc'}}>
<Components.AccountsLoginForm />
</div>
<div className="main">
<Components.MoviesList />
</div>
</div>
registerComponent('MoviesWrapper', MoviesWrapper);

View file

@ -1,6 +0,0 @@
import '../components/movies/MoviesDetails.jsx';
import '../components/movies/MoviesEditForm.jsx';
import '../components/movies/MoviesItem.jsx';
import '../components/movies/MoviesList.jsx';
import '../components/movies/MoviesNewForm.jsx';
import '../components/movies/MoviesWrapper.jsx';

View file

@ -1,12 +0,0 @@
/*
Add strings for internationalization.
*/
import { addStrings } from 'meteor/vulcan:core';
addStrings('en', {
'movies.delete': "Delete Movie",
'movies.delete_confirm': "Delete Movie?"
});

View file

@ -1,15 +0,0 @@
// The main Movies collection
import MoviesImport from './movies/collection.js';
// Text strings used in the UI
import './i18n.js';
// React components
import './components.js';
// Routes
import './routes.js';
// Add Movies collection to global Meteor namespace (optional)
Movies = MoviesImport;

View file

@ -1,35 +0,0 @@
/*
The main Movies collection definition file.
*/
import { createCollection } from 'meteor/vulcan:core';
import schema from './schema.js';
import resolvers from './resolvers.js';
import mutations from './mutations.js';
// Groups & user permissions
import './permissions.js';
// GraphQL fragments used to query for data
import './fragments.js';
// Sorting & filtering parameters
import './parameters.js';
const Movies = createCollection({
collectionName: 'movies',
typeName: 'Movie',
schema,
resolvers,
mutations,
});
export default Movies;

View file

@ -1,35 +0,0 @@
/*
Register the two GraphQL fragments used to query for data
*/
import { registerFragment } from 'meteor/vulcan:core';
registerFragment(`
fragment MoviesItemFragment on Movie {
_id
name
year
createdAt
userId
user {
displayName
}
}
`);
registerFragment(`
fragment MoviesDetailsFragment on Movie {
_id
name
createdAt
year
review
privateComments
userId
user {
displayName
}
}
`);

View file

@ -1,104 +0,0 @@
/*
Define the three default mutations:
- new (e.g.: moviesNew(document: moviesInput) : Movie )
- edit (e.g.: moviesEdit(documentId: String, set: moviesInput, unset: moviesUnset) : Movie )
- remove (e.g.: moviesRemove(documentId: String) : Movie )
Each mutation has:
- A name
- A check function that takes the current user and (optionally) the document affected
- The actual mutation
*/
import { newMutation, editMutation, removeMutation, Utils } from 'meteor/vulcan:core';
import Users from 'meteor/vulcan:users';
const performCheck = (mutation, user, document) => {
if (!mutation.check(user, document)) throw new Error(Utils.encodeIntlError({id: `app.mutation_not_allowed`, value: `"${mutation.name}" on _id "${document._id}"`}));
}
const mutations = {
new: {
name: 'moviesNew',
check(user) {
if (!user) return false;
return Users.canDo(user, 'movies.new');
},
mutation(root, {document}, context) {
performCheck(this, context.currentUser, document);
return newMutation({
collection: context.Movies,
document: document,
currentUser: context.currentUser,
validate: true,
context,
});
},
},
edit: {
name: 'moviesEdit',
check(user, document) {
if (!user || !document) return false;
return Users.owns(user, document) ? Users.canDo(user, 'movies.edit.own') : Users.canDo(user, `movies.edit.all`);
},
mutation(root, {documentId, set, unset}, context) {
const document = context.Movies.findOne(documentId);
performCheck(this, context.currentUser, document);
return editMutation({
collection: context.Movies,
documentId: documentId,
set: set,
unset: unset,
currentUser: context.currentUser,
validate: true,
context,
});
},
},
remove: {
name: 'moviesRemove',
check(user, document) {
if (!user || !document) return false;
return Users.owns(user, document) ? Users.canDo(user, 'movies.remove.own') : Users.canDo(user, `movies.remove.all`);
},
mutation(root, {documentId}, context) {
const document = context.Movies.findOne(documentId);
performCheck(this, context.currentUser, document);
return removeMutation({
collection: context.Movies,
documentId: documentId,
currentUser: context.currentUser,
validate: true,
context,
});
},
},
};
export default mutations;

View file

@ -1,19 +0,0 @@
/*
Add a new parameter callback that sorts movies by 'createdAt' property.
We use a callback instead of defining the sort in the resolver so that
the same sort can be used on the client, too.
*/
import { addCallback } from 'meteor/vulcan:core';
function sortByCreatedAt (parameters, terms) {
return {
selector: parameters.selector,
options: {...parameters.options, sort: {createdAt: -1}}
};
}
addCallback("movies.parameters", sortByCreatedAt);

View file

@ -1,14 +0,0 @@
import Users from 'meteor/vulcan:users';
const membersActions = [
'movies.new',
'movies.edit.own',
'movies.remove.own',
];
Users.groups.members.can(membersActions);
const adminActions = [
'movies.edit.all',
'movies.remove.all'
];
Users.groups.admins.can(adminActions);

View file

@ -1,62 +0,0 @@
/*
Three resolvers are defined:
- list (e.g.: moviesList(terms: JSON, offset: Int, limit: Int) )
- single (e.g.: moviesSingle(_id: String) )
- listTotal (e.g.: moviesTotal )
*/
import { GraphQLSchema } from 'meteor/vulcan:core';
// add the "user" resolver for the Movie type separately
const movieResolver = {
Movie: {
user(movie, args, context) {
return context.Users.findOne({ _id: movie.userId }, { fields: context.getViewableFields(context.currentUser, context.Users) });
},
},
};
GraphQLSchema.addResolvers(movieResolver);
// basic list, single, and total query resolvers
const resolvers = {
list: {
name: 'moviesList',
resolver(root, {terms = {}}, context, info) {
let {selector, options} = context.Movies.getParameters(terms);
options.limit = (terms.limit < 1 || terms.limit > 100) ? 100 : terms.limit;
options.fields = context.getViewableFields(context.currentUser, context.Movies);
return context.Movies.find(selector, options).fetch();
},
},
single: {
name: 'moviesSingle',
resolver(root, {documentId}, context) {
const document = context.Movies.findOne({_id: documentId});
return _.pick(document, _.keys(context.getViewableFields(context.currentUser, context.Movies, document)));
},
},
total: {
name: 'moviesTotal',
resolver(root, {terms = {}}, context) {
let {selector, options} = context.Movies.getParameters(terms);
return context.Movies.find(selector, options).count();
},
}
};
export default resolvers;

View file

@ -1,63 +0,0 @@
/*
A SimpleSchema-compatible JSON schema
*/
import Users from 'meteor/vulcan:users';
// define schema
const schema = {
_id: {
type: String,
optional: true,
viewableBy: ['guests'],
},
name: {
label: 'Name',
type: String,
viewableBy: ['guests'],
insertableBy: ['members'],
editableBy: ['members'],
},
createdAt: {
type: Date,
viewableBy: ['guests'],
autoValue: (documentOrModifier) => {
if (documentOrModifier && !documentOrModifier.$set) return new Date() // if this is an insert, set createdAt to current timestamp
}
},
year: {
label: 'Year',
type: String,
optional: true,
viewableBy: ['guests'],
insertableBy: ['members'],
editableBy: ['members'],
},
review: {
label: 'Review',
type: String,
control: 'textarea',
viewableBy: ['guests'],
insertableBy: ['members'],
editableBy: ['members']
},
privateComments: {
label: 'Private Comments',
type: String,
optional: true,
control: 'textarea',
viewableBy: Users.owns,
insertableBy: ['members'],
editableBy: ['members']
},
userId: {
type: String,
optional: true,
viewableBy: ['guests'],
resolveAs: 'user: User',
}
};
export default schema;

View file

@ -1,4 +0,0 @@
import { addRoute, getComponent } from 'meteor/vulcan:core';
// add new "/movies" route that loads the MoviesWrapper component
addRoute({ name: 'movies', path: '/', componentName: 'MoviesWrapper' });

View file

@ -1,2 +0,0 @@
import '../modules/index.js';
import './seed.js';

View file

@ -1,80 +0,0 @@
/*
Seed the database with some dummy content.
*/
import Movies from '../modules/movies/collection.js';
import Users from 'meteor/vulcan:users';
import { newMutation } from 'meteor/vulcan:core';
const seedData = [
{
name: 'Star Wars',
year: '1973',
review: `A classic.`,
privateComments: `Actually, I don't really like Star Wars…`
},
{
name: 'Die Hard',
year: '1987',
review: `A must-see if you like action movies.`,
privateComments: `I love Bruce Willis so much!`
},
{
name: 'Terminator',
year: '1983',
review: `Once again, Schwarzenegger shows why he's the boss.`,
privateComments: `Terminator is my favorite movie ever. `
},
{
name: 'Jaws',
year: '1971',
review: 'The original blockbuster.',
privateComments: `I'm scared of sharks…`
},
{
name: 'Die Hard II',
year: '1991',
review: `Another classic.`
},
{
name: 'Rush Hour',
year: '1993',
review: `Jackie Chan at his best.`,
},
{
name: 'Citizen Kane',
year: '1943',
review: `A disappointing lack of action sequences.`,
},
{
name: 'Commando',
year: '1983',
review: 'A good contender for highest kill count ever.',
},
];
Meteor.startup(function () {
if (Users.find().fetch().length === 0) {
Accounts.createUser({
username: 'DemoUser',
email: 'dummyuser@telescopeapp.org',
profile: {
isDummy: true
}
});
}
const currentUser = Users.findOne();
if (Movies.find().fetch().length === 0) {
seedData.forEach(document => {
newMutation({
action: 'movies.new',
collection: Movies,
document: document,
currentUser: currentUser,
validate: false
});
});
}
});

File diff suppressed because one or more lines are too long

View file

@ -1,23 +0,0 @@
Package.describe({
name: 'example-movies-full',
});
Package.onUse(function (api) {
api.use([
'vulcan:core',
'vulcan:forms',
'vulcan:routing',
'vulcan:accounts',
]);
api.addFiles('lib/stylesheets/bootstrap.min.css', 'client');
api.mainModule('lib/server/main.js', 'server');
api.mainModule('lib/client/main.js', 'client');
api.export([
'Movies',
], ['client', 'server']);
});

View file

@ -1,7 +1,6 @@
/*
A component to configure the "edit movie" form.
Wrapped with the "withDocument" container.
*/

View file

@ -10,10 +10,12 @@ const schema = {
_id: {
type: String,
optional: true,
viewableBy: ['guests'],
},
createdAt: {
type: Date,
optional: true,
viewableBy: ['guests'],
autoValue: (documentOrModifier) => {
if (documentOrModifier && !documentOrModifier.$set) return new Date() // if this is an insert, set createdAt to current timestamp
@ -21,6 +23,7 @@ const schema = {
},
userId: {
type: String,
optional: true,
viewableBy: ['guests'],
resolveAs: 'user: User',
},

View file

@ -5,10 +5,14 @@ Package.describe({
Package.onUse(function (api) {
api.use([
// vulcan core
'vulcan:core',
// vulcan packages
'vulcan:forms',
'vulcan:routing',
'vulcan:accounts',
]);
api.addFiles('lib/stylesheets/bootstrap.min.css');

View file

@ -19,7 +19,7 @@ import {
capitalize
} from '../../helpers.js';
const loggingInMessage = 'Logging In...';
const loggingInMessage = 'accounts.logging_in';
export class AccountsLoginForm extends Tracker.Component {
constructor(props) {

View file

@ -8,6 +8,9 @@ Package.describe({
Package.onUse(function(api) {
api.versionsFrom('1.3');
api.use('vulcan:core@1.3.0');
api.use('ecmascript');
api.use('tracker');
api.use('underscore');

View file

@ -44,7 +44,9 @@ class HeadTags extends Component {
// add <link /> markup specific to the page rendered
const link = Headtags.link.concat([
{ rel: "canonical", href: Utils.getSiteUrl() },
{ rel: "shortcut icon", href: getSetting("faviconUrl", "/img/favicon.ico") }
{ rel: "shortcut icon", href: getSetting("faviconUrl", "/img/favicon.ico") },
{ rel: 'stylesheet', type: 'text/css', href: 'https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-alpha.5/css/bootstrap.min.css' },
{ rel: 'stylesheet', type: 'text/css', href: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css' },
]);
return (

View file

@ -4,11 +4,10 @@ import React, { PropTypes, Component } from 'react';
import { intlShape } from 'react-intl';
import { withRouter } from 'react-router'
const PostsNewForm = (props, context) => {
return (
<Components.ShowIf
const PostsNewForm = (props, context) =>
<Components.ShowIf
check={Posts.options.mutations.new.check}
failureComponent={<Components.UsersAccountForm />}
failureComponent={<Components.AccountsLoginForm />}
>
<div className="posts-new-form">
<Components.SmartForm
@ -22,8 +21,6 @@ const PostsNewForm = (props, context) => {
/>
</div>
</Components.ShowIf>
);
};
PostsNewForm.propTypes = {
closeModal: React.PropTypes.func,

View file

@ -16,9 +16,7 @@ Package.onUse(function (api) {
'vulcan:comments@1.3.0',
'vulcan:voting@1.3.0',
'vulcan:accounts@1.3.0',
// third-party packages
'fortawesome:fontawesome@4.5.0',
'vulcan:categories@1.3.0',
]);
api.mainModule("lib/server.js", "server");

View file

@ -11,11 +11,11 @@ Package.onUse(function (api) {
api.use([
'vulcan:core@1.3.0',
'fourseven:scss',
'fourseven:scss@4.5.0',
]);
api.addFiles([
'lib/stylesheets/bootstrap.css',
// 'lib/stylesheets/bootstrap.css',
'lib/stylesheets/main.scss'
], ['client']);

View file

@ -13,7 +13,6 @@ Posts.addField([
fieldName: 'cloudinaryUrls',
fieldSchema: {
type: Array,
blackbox: true,
optional: true,
viewableBy: ['guests'],
resolveAs: 'cloudinaryUrls: [JSON]',
@ -23,6 +22,7 @@ Posts.addField([
fieldName: 'cloudinaryUrls.$',
fieldSchema: {
type: Object,
blackbox: true,
optional: true
}
}

View file

@ -93,31 +93,27 @@ function cachePostThumbnailOnSubmit (post) {
const data = CloudinaryUtils.uploadImage(post.thumbnailUrl);
if (data) {
Posts.update(post._id, {$set:{
cloudinaryId: data.cloudinaryId,
cloudinaryUrls: data.urls
}});
post.cloudinaryId = data.cloudinaryId;
post.cloudinaryUrls = data.urls;
}
}
}
return post;
}
addCallback("posts.new.async", cachePostThumbnailOnSubmit);
addCallback("posts.new.sync", cachePostThumbnailOnSubmit);
// post edit callback
function cachePostThumbnailOnEdit (newPost, oldPost) {
function cachePostThumbnailOnEdit (modifier, oldPost) {
if (getSetting("cloudinaryAPIKey")) {
if (newPost.thumbnailUrl && newPost.thumbnailUrl !== oldPost.thumbnailUrl) {
const data = CloudinaryUtils.uploadImage(newPost.thumbnailUrl);
Posts.update(newPost._id, {$set:{
cloudinaryId: data.cloudinaryId,
cloudinaryUrls: data.urls
}});
if (modifier.$set.thumbnailUrl && modifier.$set.thumbnailUrl !== oldPost.thumbnailUrl) {
const data = CloudinaryUtils.uploadImage(modifier.$set.thumbnailUrl);
modifier.$set.cloudinaryId = data.cloudinaryId;
modifier.$set.cloudinaryUrls = data.urls;
}
}
return modifier;
}
addCallback("posts.edit.async", cachePostThumbnailOnEdit);
addCallback("posts.edit.sync", cachePostThumbnailOnEdit);
export default CloudinaryUtils;

View file

@ -19,7 +19,7 @@ const withCurrentUser = component => {
props(props) {
const {data: {loading, currentUser}} = props;
return {
loading,
currentUserLoading: loading,
currentUser,
};
},

View file

@ -5,7 +5,8 @@ import { getFragment, getFragmentName } from 'meteor/vulcan:core';
export default function withDocument (options) {
const { queryName, collection, pollInterval = 20000 } = options,
const { collection, pollInterval = 20000 } = options,
queryName = options.queryName || `${collection.options.collectionName}SingleQuery`,
fragment = options.fragment || getFragment(options.fragmentName),
fragmentName = getFragmentName(fragment),
singleResolverName = collection.options.resolvers.single && collection.options.resolvers.single.name;
@ -13,6 +14,7 @@ export default function withDocument (options) {
return graphql(gql`
query ${queryName}($documentId: String, $slug: String) {
${singleResolverName}(documentId: $documentId, slug: $slug) {
__typename
...${fragmentName}
}
}

View file

@ -32,7 +32,7 @@ export default function withEdit(options) {
const {collection } = options,
fragment = options.fragment || getFragment(options.fragmentName),
fragmentName = getFragmentName(fragment),
collectionName = collection._name,
collectionName = collection.options.collectionName,
mutationName = collection.options.mutations.edit.name;
return graphql(gql`

View file

@ -45,7 +45,7 @@ import { withApollo } from 'react-apollo';
const withList = (options) => {
const { collection, limit = 10, pollInterval = 20000 } = options,
queryName = options.queryName || `${collection._name}ListQuery`,
queryName = options.queryName || `${collection.options.collectionName}ListQuery`,
fragment = options.fragment || getFragment(options.fragmentName),
fragmentName = getFragmentName(fragment),
listResolverName = collection.options.resolvers.list && collection.options.resolvers.list.name,
@ -76,6 +76,7 @@ const withList = (options) => {
query ${queryName}($terms: JSON) {
${totalResolverName}(terms: $terms)
${listResolverName}(terms: $terms) {
__typename
...${fragmentName}
}
}

View file

@ -32,7 +32,7 @@ export default function withNew(options) {
const { collection } = options,
fragment = options.fragment || getFragment(options.fragmentName),
fragmentName = getFragmentName(fragment),
collectionName = collection._name,
collectionName = collection.options.collectionName,
mutationName = collection.options.mutations.new.name;
// wrap component with graphql HoC

View file

@ -13,7 +13,7 @@ export {
// fragments
Fragments, registerFragment, getFragment, getFragmentName, extendFragment,
// graphql
GraphQLSchema, addGraphQLSchema, addGraphQLQuery, addGraphQLMutation, addGraphQLResolvers, addToGraphQLContext,
GraphQLSchema, addGraphQLSchema, addGraphQLQuery, addGraphQLMutation, addGraphQLResolvers, removeGraphQLResolver, addToGraphQLContext,
// headtags
Headtags,
// inject data

View file

@ -12,6 +12,7 @@ Package.onUse(function(api) {
api.use([
'vulcan:lib@1.3.0',
'vulcan:users@1.3.0',
'vulcan:routing@1.3.0'
]);
api.imply([

View file

@ -11,7 +11,7 @@ Package.onUse(function (api) {
api.use([
'fourseven:scss',
'fourseven:scss@4.5.0',
// Vulcan packages

View file

@ -10,7 +10,7 @@ Package.onUse(function (api) {
api.versionsFrom(['METEOR@1.0']);
api.use([
'vulcan:lib@1.3.0'
'vulcan:core@1.3.0'
]);
api.mainModule("lib/server.js", "server");

View file

@ -2,11 +2,11 @@ import Posts from "meteor/vulcan:posts";
import { addCallback, getSetting } from 'meteor/vulcan:core';
function getEmbedlyData(url) {
var data = {};
var extractBase = 'http://api.embed.ly/1/extract';
var embedlyKey = getSetting('embedlyKey');
// 200 x 200 is the minimum size accepted by facebook
var thumbnailWidth = getSetting('thumbnailWidth', 200);
var thumbnailHeight = getSetting('thumbnailHeight', 125);
var thumbnailHeight = getSetting('thumbnailHeight', 200);
if(!embedlyKey) {
// fail silently to still let the post be submitted as usual
@ -26,10 +26,8 @@ function getEmbedlyData(url) {
}
});
// console.log(result)
if (!!result.data.images && !!result.data.images.length) // there may not always be an image
result.data.thumbnailUrl = result.data.images[0].url.replace("http:", ""); // add thumbnailUrl as its own property and remove "http"
result.data.thumbnailUrl = result.data.images[0].url.replace("http:","") // add thumbnailUrl as its own property
if (result.data.authors && result.data.authors.length > 0) {
result.data.sourceName = result.data.authors[0].name;

View file

@ -12,7 +12,7 @@ Package.onUse( function(api) {
api.use([
'vulcan:core@1.3.0',
'vulcan:posts@1.3.0',
'fourseven:scss'
'fourseven:scss@4.5.0'
]);
api.addFiles([

View file

@ -0,0 +1,153 @@
# nova-upload
🏖🔭 Vulcan package extending `vulcan:forms` to upload images to Cloudinary from a drop zone.
![Screenshot](https://res.cloudinary.com/xavcz/image/upload/v1471534203/Capture_d_e%CC%81cran_2016-08-17_14.22.14_ehwv0d.png)
Want to add this to your Vulcan instance? Read below:
# Installation
### 1. Meteor package
I would recommend that you clone this repo in your vulcan's `/packages` folder.
Then, open the `.meteor/packages` file and add at the end of the **Optional packages** section:
`xavcz:nova-forms-upload`
> **Note:** This is the version for Nova 1.0.0, running with GraphQL. *If you are looking for a version compatible with Nova "classic", you'll need to change the package's branch, like below. Then, refer to [the README for `nova-forms-upload` on Nova Classic](https://github.com/xavcz/nova-forms-upload/blob/nova-classic/README.md#installation)*
```bash
# only for Nova classic users (v0.27.5)
cd nova-forms-upload
git checkout nova-classic
```
### 2. NPM dependency
This package depends on the awesome `react-dropzone` ([repo](https://github.com/okonet/react-dropzone)), you need to install the dependency:
```
npm install react-dropzone isomorphic-fetch
```
### 3. Cloudinary account
Create a [Cloudinary account](https://cloudinary.com) if you don't have one.
The upload to Cloudinary relies on **unsigned upload**:
> Unsigned upload is an option for performing upload directly from a browser or mobile application with no authentication signature, and without going through your servers at all. However, for security reasons, not all upload parameters can be specified directly when performing unsigned upload calls.
Unsigned upload options are controlled by [an upload preset](http://cloudinary.com/documentation/upload_images#upload_presets), so in order to use this feature you first need to enable unsigned uploading for your Cloudinary account from the [Upload Settings](https://cloudinary.com/console/settings/upload) page.
When creating your **preset**, you can define image transformations. I recommend to set something like 200px width & height, fill mode and auto quality. Once created, you will get a preset id.
It may look like this:
![Screenshot-Cloudinary](https://res.cloudinary.com/xavcz/image/upload/v1471534183/Capture_d_e%CC%81cran_2016-08-18_17.07.52_tr9uoh.png)
### 4. Nova Settings
Edit your `settings.json` and add inside the `public: { ... }` block the following entries with your own credentials:
```json
public: {
"cloudinaryCloudName": "YOUR_APP_NAME",
"cloudinaryPresets": {
"avatar": "YOUR_PRESET_ID",
"posts": "THE_SAME_OR_ANOTHER_PRESET_ID"
}
}
```
Picture upload in Nova is now enabled! Easy-peasy, right? 👯
### 5. Your custom package & custom fields
Make your custom package depends on this package: open `package.js` in your custom package and add `xavcz:nova-forms-upload` as a dependency, near by the other `nova:xxx` packages.
You can now use the `Upload` component as a classic form extension with [custom fields](https://www.youtube.com/watch?v=1yTT48xaSy8) like `nova:forms-tags` or `nova:embedly`.
**⚠️ Note:** Don't forget to update your query fragments wherever needed after defining your custom fields, else they will never be available!
## Image for posts
Let's say you want to enhance your posts with a custom image. In your custom package, your new custom field could look like this:
```js
// ... your imports
import { getComponent, getSetting } from 'meteor/nova:lib';
import Posts from 'meteor/nova:posts';
// extends Posts schema with a new field: 'image' 🏖
Posts.addField({
fieldName: 'image',
fieldSchema: {
type: String,
optional: true,
control: getComponent('Upload'),
insertableBy: ['members'],
editableBy: ['members'],
viewableBy: ['guests'],
form: {
options: {
preset: getSetting('cloudinaryPresets').posts // this setting refers to the transformation you want to apply to the image
},
}
}
});
```
## Avatar for users
Let's say you want to enable your users to upload their own avatar. In your custom package, your new custom field could look like this:
```js
// ... your imports
import { getComponent, getSetting } from 'meteor/nova:lib';
import Users from 'meteor/nova:users';
// extends Users schema with a new field: 'avatar' 👁
Users.addField({
fieldName: 'avatar',
fieldSchema: {
type: String,
optional: true,
control: getComponent('Upload'),
insertableBy: ['members'],
editableBy: ['members'],
viewableBy: ['guests'],
preload: true, // ⚠️ will preload the field for the current user!
form: {
options: {
preset: getSetting('cloudinaryPresets').avatar // this setting refers to the transformation you want to apply to the image
},
}
}
});
```
Adding the opportunity to upload an avatar comes with a trade-off: you also need to extend the behavior of the `Users.avatar` methods. You can do this by adding this snippet, in `custom_fields.js` for instance:
```js
const originalAvatarConstructor = Users.avatar;
// extends the Users.avatar function
Users.avatar = {
...originalAvatarConstructor,
getUrl(user) {
url = originalAvatarConstructor.getUrl(user);
return !!user && user.avatar ? user.avatar : url;
},
};
```
Now, you also need to update the query fragments related to `User` when you want the custom avatar to show up :)
## S3? Google Cloud?
Feel free to contribute to add new features and flexibility to this package :)
You are welcome to come chat about it [on the Nova Slack chatroom](http://slack.telescopeapp.org)
## What about `nova:cloudinary` ?
This package and `nova:cloudinary` share a settings in common: `cloudinaryCloudName`. They are fully compatible.
Happy hacking! 🚀

View file

@ -0,0 +1,123 @@
import { Components, getSetting, registerComponent } from 'meteor/vulcan:lib';
import React, { PropTypes, Component } from 'react';
import Dropzone from 'react-dropzone';
import 'isomorphic-fetch'; // patch for browser which don't have fetch implemented
class Upload extends Component {
constructor(props) {
super(props);
this.onDrop = this.onDrop.bind(this);
this.clearImage = this.clearImage.bind(this);
this.state = {
preview: '',
uploading: false,
value: props.value || '',
}
}
componentWillMount() {
this.context.addToAutofilledValues({[this.props.name]: this.props.value || ''});
}
onDrop(files) {
console.log(this)
// set the component in upload mode with the preview
this.setState({
preview: files[0].preview,
uploading: true,
value: '',
});
// request url to cloudinary
const cloudinaryUrl = `https://api.cloudinary.com/v1_1/${getSetting("cloudinaryCloudName")}/upload`;
// request body
const body = new FormData();
body.append("file", files[0]);
body.append("upload_preset", this.props.options.preset);
// post request to cloudinary
fetch(cloudinaryUrl, {
method: "POST",
body,
})
.then(res => res.json()) // json-ify the readable strem
.then(body => {
// use the https:// url given by cloudinary
const avatarUrl = body.secure_url;
// set the uploading status to false
this.setState({
preview: '',
uploading: false,
value: avatarUrl,
});
// tell vulcanForm to catch the value
this.context.addToAutofilledValues({[this.props.name]: avatarUrl});
})
.catch(err => console.log("err", err));
}
clearImage(e) {
e.preventDefault();
this.context.addToAutofilledValues({[this.props.name]: ''});
this.setState({
preview: '',
value: '',
});
}
render() {
const { uploading, preview, value } = this.state;
// show the actual uploaded image or the preview
const image = preview || value;
return (
<div className="form-group row">
<label className="control-label col-sm-3">{this.props.label}</label>
<div className="col-sm-9">
<div className="upload-field">
<Dropzone ref="dropzone"
multiple={false}
onDrop={this.onDrop}
accept="image/*"
className="dropzone-base"
activeClassName="dropzone-active"
rejectClassName="dropzone-reject"
>
<div>Drop an image here, or click to select an image to upload.</div>
</Dropzone>
{image ?
<div className="upload-state">
{uploading ? <span>Uploading... Preview:</span> : null}
{value ? <a onClick={this.clearImage}><Components.Icon name="close"/> Remove image</a> : null}
<img style={{height: 120}} src={image} />
</div>
: null}
</div>
</div>
</div>
);
}
}
Upload.propTypes = {
name: React.PropTypes.string,
value: React.PropTypes.any,
label: React.PropTypes.string
};
Upload.contextTypes = {
addToAutofilledValues: React.PropTypes.func,
}
registerComponent('Upload', Upload);
export default Upload;

View file

@ -0,0 +1,30 @@
.upload-field {
display: flex;
align-items: center;
justify-content: flex-start;
}
.dropzone-base {
border: 4px dashed #ccc;
padding: 30px;
transition: "all 0.5s";
width: 300px;
cursor: pointer;
color: #ccc;
}
.dropzone-active {
border: #4FC47F 4px solid;
}
.dropzone-reject {
border: #DD3A0A 4px solid;
}
.upload-state {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
flex-basis: 300px;
}

View file

@ -0,0 +1,3 @@
import Upload from "./Upload.jsx";
export default Upload;

View file

@ -0,0 +1,24 @@
Package.describe({
name: "vulcan:forms-upload",
summary: "Vulcan package extending vulcan:forms to upload images to Cloudinary from a drop zone.",
version: "1.3.0",
git: 'https://github.com/xavcz/nova-forms-upload.git'
});
Package.onUse( function(api) {
api.versionsFrom("METEOR@1.0");
api.use([
'vulcan:core@1.3.0',
'vulcan:forms@1.3.0',
'fourseven:scss@4.5.0'
]);
api.addFiles([
"lib/Upload.scss"
], "client");
api.mainModule("lib/modules.js", ["client", "server"]);
});

View file

@ -11,12 +11,8 @@ Package.onUse(function(api) {
api.use([
'vulcan:core@1.3.0',
'vulcan:users@1.3.0',
'ecmascript',
'check',
'aldeed:collection2-core@2.0.0',
'fourseven:scss@3.8.0'
'fourseven:scss@4.5.0'
]);
api.addFiles([

View file

@ -1,40 +0,0 @@
import Posts from "meteor/vulcan:posts";
import Comments from "meteor/vulcan:comments";
import Users from 'meteor/vulcan:users';
import { addCallback } from 'meteor/vulcan:core';
Users.addField({
fieldName: 'isDummy',
fieldSchema: {
type: Boolean,
optional: true,
hidden: true // never show this
}
});
Posts.addField({
fieldName: 'dummySlug',
fieldSchema: {
type: String,
optional: true,
hidden: true // never show this
}
});
Posts.addField({
fieldName: 'isDummy',
fieldSchema: {
type: Boolean,
optional: true,
hidden: true // never show this
}
});
Comments.addField({
fieldName: 'isDummy',
fieldSchema: {
type: Boolean,
optional: true,
hidden: true // never show this
}
});

View file

@ -5,6 +5,27 @@ import Comments from "meteor/vulcan:comments";
import Users from 'meteor/vulcan:users';
import Events from "meteor/vulcan:events";
const dummyFlag = {
fieldName: 'isDummy',
fieldSchema: {
type: Boolean,
optional: true,
hidden: true
}
}
Users.addField(dummyFlag);
Posts.addField(dummyFlag);
Comments.addField(dummyFlag);
Posts.addField({
fieldName: 'dummySlug',
fieldSchema: {
type: String,
optional: true,
hidden: true // never show this
}
});
var toTitleCase = function (str) {
return str.replace(/\w\S*/g, function(txt){return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();});
};
@ -58,6 +79,7 @@ var createComment = function (slug, username, body, parentBody) {
};
var createDummyUsers = function () {
console.log('// inserting dummy users…');
Accounts.createUser({
username: 'Bruce',
email: 'dummyuser1@telescopeapp.org',
@ -82,6 +104,7 @@ var createDummyUsers = function () {
};
var createDummyPosts = function () {
console.log('// inserting dummy posts');
createPost("read_this_first", moment().toDate(), "Bruce", "telescope.png");
@ -96,6 +119,7 @@ var createDummyPosts = function () {
};
var createDummyComments = function () {
console.log('// inserting dummy comments…');
createComment("read_this_first", "Bruce", "What an awesome app!");
@ -109,32 +133,21 @@ var createDummyComments = function () {
};
var deleteDummyContent = function () {
const deleteDummyContent = function () {
Users.remove({'profile.isDummy': true});
Posts.remove({isDummy: true});
Comments.remove({isDummy: true});
};
Meteor.methods({
addGettingStartedContent: function () {
if (Users.isAdmin(Meteor.user())) {
createDummyUsers();
createDummyPosts();
createDummyComments();
}
},
removeGettingStartedContent: function () {
if (Users.isAdmin(Meteor.user()))
deleteDummyContent();
}
});
Meteor.startup(function () {
// insert dummy content only if createDummyContent hasn't happened and there aren't any posts or users in the db
if (!Users.find().count() && !Events.findOne({name: 'createDummyContent'}) && !Posts.find().count()) {
if (!Users.find().count()) {
createDummyUsers();
}
if (!Posts.find().count()) {
createDummyPosts();
}
if (!Comments.find().count()) {
createDummyComments();
Events.log({name: 'createDummyContent', unique: true, important: true});
}
});

View file

@ -20,12 +20,6 @@ Package.onUse(function (api) {
'vulcan:events@1.3.0',
]);
// both
api.addFiles([
'lib/getting_started.js'
], ['client', 'server']);
// client
api.addAssets([
@ -36,7 +30,7 @@ Package.onUse(function (api) {
// server
api.addFiles([
'lib/server/dummy_content.js'
'lib/server/seed.js'
], ['server']);
api.addAssets('content/read_this_first.md', 'server');

View file

@ -30,6 +30,7 @@ addStrings('en', {
"accounts.or_use": "or use",
"accounts.info_email_sent": "Email sent.",
"accounts.info_password_changed": "Password changed.",
"accounts.logging_in": "Logging in…",
"forms.submit": "Submit",
"forms.cancel": "Cancel",

View file

@ -78,7 +78,8 @@ const meteorClientConfig = networkInterfaceConfig => ({
ssrMode: Meteor.isServer,
networkInterface: createMeteorNetworkInterface(networkInterfaceConfig),
queryDeduplication: true, // http://dev.apollodata.com/core/network.html#query-deduplication
addTypename: true,
// Default to using Mongo _id, must use _id for queries.
dataIdFromObject(result) {
if (result._id && result.__typename) {

View file

@ -11,12 +11,19 @@ SimpleSchema.extendOptions([
]);
/**
* @summary Meteor Collections.
* @summary replacement for Collection2's attachSchema
* @class Mongo.Collection
*/
Mongo.Collection.prototype.attachSchema = function (schemaOrFields) {
if (schemaOrFields instanceof SimpleSchema) {
this.simpleSchema = () => schemaOrFields;
} else {
this.simpleSchema().extend(schemaOrFields)
}
}
/**
* @summary @summary Add an additional field (or an array of fields) to a schema.
* @summary Add an additional field (or an array of fields) to a schema.
* @param {Object|Object[]} field
*/
Mongo.Collection.prototype.addField = function (fieldOrFieldArray) {
@ -89,10 +96,10 @@ Mongo.Collection.prototype.helpers = function(helpers) {
export const createCollection = options => {
const {collectionName, typeName, schema, resolvers, mutations, generateGraphQLSchema = true } = options;
const {collectionName, typeName, schema, resolvers, mutations, generateGraphQLSchema = true, dbCollectionName } = options;
// initialize new Mongo collection
const collection = collectionName === 'users' ? Meteor.users : new Mongo.Collection(collectionName);
const collection = collectionName === 'users' ? Meteor.users : new Mongo.Collection(dbCollectionName ? dbCollectionName : collectionName.toLowerCase());
// decorate collection with options
collection.options = options;

View file

@ -85,6 +85,9 @@ export const GraphQLSchema = {
addResolvers(resolvers) {
this.resolvers = deepmerge(this.resolvers, resolvers);
},
removeResolver(typeName, resolverName) {
delete this.resolvers[typeName][resolverName];
},
// add objects to context
context: {},
@ -95,7 +98,8 @@ export const GraphQLSchema = {
// generate a GraphQL schema corresponding to a given collection
generateSchema(collection) {
const collectionName = collection._name;
const collectionName = collection.options.collectionName;
const mainTypeName = collection.typeName ? collection.typeName : Utils.camelToSpaces(_.initial(collectionName).join('')); // default to posts -> Post
// backward-compatibility code: we do not want user.telescope fields in the graphql schema
@ -160,4 +164,5 @@ export const addGraphQLSchema = GraphQLSchema.addSchema.bind(GraphQLSchema);
export const addGraphQLQuery = GraphQLSchema.addQuery.bind(GraphQLSchema);
export const addGraphQLMutation = GraphQLSchema.addMutation.bind(GraphQLSchema);
export const addGraphQLResolvers = GraphQLSchema.addResolvers.bind(GraphQLSchema);
export const removeGraphQLResolver = GraphQLSchema.removeResolver.bind(GraphQLSchema);
export const addToGraphQLContext = GraphQLSchema.addToContext.bind(GraphQLSchema);

View file

@ -14,7 +14,7 @@ import './mongo_redux.js';
export { Components, registerComponent, replaceComponent, getRawComponent, getComponent, copyHoCs, populateComponentsApp } from './components.js';
export { createCollection } from './collections.js';
export { Callbacks, addCallback, removeCallback, runCallbacks, runCallbacksAsync } from './callbacks.js';
export { GraphQLSchema, addGraphQLSchema, addGraphQLQuery, addGraphQLMutation, addGraphQLResolvers, addToGraphQLContext } from './graphql.js';
export { GraphQLSchema, addGraphQLSchema, addGraphQLQuery, addGraphQLMutation, addGraphQLResolvers, removeGraphQLResolver, addToGraphQLContext } from './graphql.js';
export { Routes, addRoute, getRoute, populateRoutesApp } from './routes.js';
export { Utils } from './utils.js';
export { getSetting } from './settings.js';

View file

@ -205,7 +205,7 @@ Utils.sanitize = function(s) {
if(Meteor.isServer){
s = sanitizeHtml(s, {
allowedTags: [
'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul',
'ol', 'nl', 'li', 'b', 'i', 'strong', 'em', 'strike',
'code', 'hr', 'br', 'div', 'table', 'thead', 'caption',
'tbody', 'tr', 'th', 'td', 'pre', 'img'

View file

@ -37,17 +37,20 @@ export const newMutation = ({ collection, document, currentUser, validate, conte
// we don't want to modify the original document
let newDocument = Object.assign({}, document);
const collectionName = collection._name;
const schema = collection.simpleSchema()._schema;
// if document is not trusted, run validation steps
if (validate) {
// validate document
collection.simpleSchema().validate(document);
// check that the current user has permission to insert each field
_.keys(newDocument).forEach(function (fieldName) {
_.keys(newDocument).forEach(fieldName => {
var field = schema[fieldName];
if (!context.Users.canInsertField (currentUser, field)) {
if (!field || !context.Users.canInsertField (currentUser, field)) {
throw new Error(Utils.encodeIntlError({id: 'app.disallowed_property_detected', value: fieldName}));
}
});
@ -55,11 +58,21 @@ export const newMutation = ({ collection, document, currentUser, validate, conte
// run validation callbacks
newDocument = runCallbacks(`${collectionName}.new.validate`, newDocument, currentUser);
}
// check if userId field is in the schema and add it to document if needed
const userIdInSchema = Object.keys(schema).find(key => key === 'userId');
if (!!userIdInSchema && !newDocument.userId) newDocument.userId = currentUser._id;
// run autoValue step
_.keys(schema).forEach(fieldName => {
if (!newDocument[fieldName] && schema[fieldName].autoValue) {
const autoValue = schema[fieldName].autoValue(newDocument);
if (autoValue && typeof autoValue.$setOnInsert === 'undefined') {
newDocument[fieldName] = autoValue;
}
}
});
// TODO: find that info in GraphQL mutations
// if (Meteor.isServer && this.connection) {
// post.userIP = this.connection.clientAddress;
@ -105,11 +118,14 @@ export const editMutation = ({ collection, documentId, set, unset, currentUser,
// if document is not trusted, run validation steps
if (validate) {
// validate modifiers
collection.simpleSchema().newContext().validate({$set: set, $unset: unset}, { modifier: true });
// check that the current user has permission to edit each field
const modifiedProperties = _.keys(set).concat(_.keys(unset));
modifiedProperties.forEach(function (fieldName) {
var field = schema[fieldName];
if (!context.Users.canEditField(currentUser, field, document)) {
if (!field || !context.Users.canEditField(currentUser, field, document)) {
throw new Error(Utils.encodeIntlError({id: 'app.disallowed_property_detected', value: fieldName}));
}
});
@ -118,11 +134,29 @@ export const editMutation = ({ collection, documentId, set, unset, currentUser,
modifier = runCallbacks(`${collectionName}.edit.validate`, modifier, document, currentUser);
}
// run autoValue step
_.keys(schema).forEach(fieldName => {
if (!modifier.$set[fieldName] && schema[fieldName].autoValue) {
const autoValue = schema[fieldName].autoValue(modifier);
if (autoValue && typeof autoValue.$setOnInsert === 'undefined') {
modifier.$set[fieldName] = autoValue;
}
}
});
// run sync callbacks (on mongo modifier)
modifier = runCallbacks(`${collectionName}.edit.sync`, modifier, document, currentUser);
// remove empty modifiers
if (_.isEmpty(modifier.$set)) {
delete modifier.$set;
}
if (_.isEmpty(modifier.$unset)) {
delete modifier.$unset;
}
// update document
collection.update(documentId, modifier);
collection.update(documentId, modifier, {removeEmptyStrings: false});
// get fresh copy of document from db
const newDocument = collection.findOne(documentId);
@ -131,6 +165,7 @@ export const editMutation = ({ collection, documentId, set, unset, currentUser,
runCallbacksAsync(`${collectionName}.edit.async`, newDocument, document, currentUser, collection);
// console.log("// edit mutation finished")
// console.log(modifier)
// console.log(newDocument)
return newDocument;

View file

@ -34,9 +34,11 @@ function isAppUrl(req) {
return false;
}
// we only need to support HTML pages only
// this is a check to do it
return /html/.test(req.headers.accept);
// we only need to support HTML pages only for browsers
// Facebook's scraper uses a request header Accepts: */*
// so allow either
const facebookAcceptsHeader = new RegExp("/*\/*/");
return /html/.test(req.headers.accept) || facebookAcceptsHeader.test(req.headers.accept);
}
// for meteor.user

View file

@ -23,17 +23,15 @@ Package.onUse(function (api) {
'check',
'http',
'email',
'tracker',
'ecmascript',
'service-configuration',
'shell-server',
// Third-party packages
'aldeed:collection2-core@2.0.0',
// 'aldeed:collection2-core@2.0.0',
'meteorhacks:picker@1.0.3',
'percolatestudio:synced-cron@1.1.0',
'jparker:gravatar@0.4.1',
'meteorhacks:inject-initial@1.0.4',
];

View file

@ -1,7 +1,6 @@
import Newsletter from './namespace.js';
import Newsletters from './collection.js';
import './emails.js';
import './custom_fields.js';
import './fragments.js';
export default Newsletter;
export default Newsletters;

View file

@ -0,0 +1,37 @@
import SimpleSchema from 'simpl-schema';
const Newsletters = new Mongo.Collection('newsletters');
const schema = {
_id: {
type: String,
},
createdAt: {
type: Date,
optional: true,
},
userId: {
type: String,
optional: true,
},
scheduledAt: {
type: Date,
optional: true,
},
subject: {
type: String,
optional: true,
},
html: {
type: String,
optional: true,
},
provider: {
type: String,
optional: true,
},
}
Newsletters.attachSchema(new SimpleSchema(schema));
export default Newsletters;

View file

@ -1,3 +0,0 @@
const Newsletter = {};
export default Newsletter;

View file

@ -1,5 +1,4 @@
import Newsletter from "./namespace.js";
import Newsletters from './collection.js';
import './emails.js';
import './custom_fields.js';
import './fragments.js';
@ -11,4 +10,4 @@ import './server/emails.js';
import './server/mutations.js';
import './server/callbacks.js';
export default Newsletter;
export default Newsletters;

View file

@ -1,6 +1,6 @@
import { SyncedCron } from 'meteor/percolatestudio:synced-cron';
import moment from 'moment';
import Newsletter from '../namespace.js';
import Newsletters from '../collection.js';
import { getSetting } from 'meteor/vulcan:core';
const defaultFrequency = [1]; // every monday
@ -63,7 +63,7 @@ var addJob = function () {
if (process.env.NODE_ENV === "production" || getSetting("enableNewsletterInDev", false)) {
console.log("// Scheduling newsletter…"); // eslint-disable-line
console.log(new Date()); // eslint-disable-line
Newsletter.scheduleNextWithMailChimp();
Newsletters.scheduleNextWithMailChimp();
}
}
});

View file

@ -1,5 +1,5 @@
import VulcanEmail from 'meteor/vulcan:email';
import Newsletter from "../namespace.js";
import Newsletters from "../collection.js";
import { getSetting } from 'meteor/vulcan:core';
// Extend email objects with server-only properties
@ -9,7 +9,7 @@ VulcanEmail.emails.newsletter = {
...VulcanEmail.emails.newsletter,
getNewsletter() {
return Newsletter.build(Newsletter.getPosts(getSetting('postsPerNewsletter', 5)));
return Newsletters.build(Newsletters.getPosts(getSetting('postsPerNewsletter', 5)));
},
subject() {
@ -18,11 +18,11 @@ VulcanEmail.emails.newsletter = {
getTestHTML() {
var campaign = this.getNewsletter();
var newsletterEnabled = '<div class="newsletter-enabled"><strong>Newsletter Enabled:</strong> '+getSetting('enableNewsletter', true)+'</div>';
var mailChimpAPIKey = '<div class="mailChimpAPIKey"><strong>mailChimpAPIKey:</strong> '+(typeof getSetting('mailChimpAPIKey') !== "undefined")+'</div>';
var mailChimpListId = '<div class="mailChimpListId"><strong>mailChimpListId:</strong> '+(typeof getSetting('mailChimpListId') !== "undefined")+'</div>';
var campaignSubject = '<div class="campaign-subject"><strong>Subject:</strong> '+campaign.subject+' (note: contents might change)</div>';
var campaignSchedule = '<div class="campaign-schedule"><strong>Scheduled for:</strong> '+ Meteor.call('getNextJob') +'</div>';
var newsletterEnabled = `<div class="newsletter-enabled"><strong>Newsletter Enabled:</strong> ${getSetting('enableNewsletter', true)}</div>`;
var mailChimpAPIKey = `<div class="mailChimpAPIKey"><strong>mailChimpAPIKey:</strong> ${typeof getSetting('mailChimpAPIKey') !== "undefined"}</div>`;
var mailChimpListId = `<div class="mailChimpListId"><strong>mailChimpListId:</strong> ${typeof getSetting('mailChimpListId') !== "undefined"}</div>`;
var campaignSubject = `<div class="campaign-subject"><strong>Subject:</strong> ${campaign.subject} (note: contents might change)</div>`;
var campaignSchedule = `<div class="campaign-schedule"><strong>Scheduled for:</strong> ${Newsletters.getNextScheduled()}</div>`;
return newsletterEnabled+mailChimpAPIKey+mailChimpListId+campaignSubject+campaignSchedule+campaign.html;
}

Some files were not shown because too many files have changed in this diff Show more