├── .babelrc
├── .eslintrc
├── .gitignore
├── CHANGELOG.md
├── CONTRIBUTING.md
├── GETTING_STARTED.md
├── LICENCE
├── README.md
├── circle.yml
├── css
├── base.css
├── icon.eot
└── icon.woff
├── data
└── form.json
├── dist
├── team-directory.css
└── team-directory.js
├── index.html
├── index.js
├── package.json
├── src
├── actions
│ └── index.js
├── components
│ ├── error.js
│ ├── filter.js
│ ├── form.js
│ └── form_types
│ │ ├── add.js
│ │ ├── add_single.js
│ │ ├── checkbox.js
│ │ ├── native.js
│ │ ├── radio.js
│ │ ├── select.js
│ │ └── textarea.js
├── constants
│ └── action_types.js
├── containers
│ ├── app.js
│ ├── edit.js
│ ├── index.js
│ ├── new.js
│ └── notfound.js
├── index.js
├── initial_state.js
├── modal_style.js
├── reducers
│ └── index.js
└── utils.js
├── team.json
└── test
└── test.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "stage": 0,
3 | "env": {
4 | "development": {
5 | "plugins": [
6 | "object-assign"
7 | ]
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "node": true,
4 | "browser": true
5 | },
6 | "parser": "babel-eslint",
7 | "plugins": [
8 | "react"
9 | ],
10 | "rules": {
11 | "curly": [0],
12 | "react/prop-types": 1,
13 | "react/no-multi-comp": 2,
14 | "react/no-danger": 2,
15 | "react/display-name": [2, { "acceptTranspilerName": true }],
16 | "react/no-unknown-property": [2],
17 | "react/prop-types": 2,
18 | "react/require-extension": [2, { "extensions": [".js", ".jsx"] }],
19 | "react/self-closing-comp": [2],
20 | "no-alert": [0],
21 | "new-cap": [0],
22 | "quotes": [2, "single"],
23 | "eol-last": [0],
24 | "no-mixed-requires": [0],
25 | "camelcase": [0],
26 | "consistent-return": [0],
27 | "no-underscore-dangle": [0],
28 | "comma-spacing": [0],
29 | "key-spacing": [0],
30 | "no-use-before-define": [0]
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .env.sh
3 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ### v1.6.5
2 |
3 | - [bug] Fix bad output when exporting JSON to CSV
4 |
5 | ### v1.6.4
6 |
7 | - [bug] Fix cases where the response returned from GitHub is not a Base64 encoded
8 | string.
9 |
10 | ### v1.6.3
11 |
12 | - [bug] UTF-8 encode non-ASCII names in vCards [#22](https://github.com/mapbox/team-directory/pull/22)
13 |
14 | ### v1.6.2
15 |
16 | - [bug] Drop `toString` method on `defaultChecked` condition in radio elements
17 | - [ui] Better label to describe downloading all contacts on listing page
18 |
19 | ### v1.6.1
20 |
21 | - [bug] Better handling of `boolean` values
22 | - [ui] Clear the select form type after selection
23 | - [ui] Style autocomplete to better align with existing style
24 |
25 | ### v1.6.0
26 |
27 | - [Feature] Add a autocomplete select from defined list form type [#17](https://github.com/mapbox/team-directory/issues/17)
28 | - [Internal] Break form items into components [#13](https://github.com/mapbox/team-directory/issues/13)
29 |
30 | ### v1.5.0
31 |
32 | - [UI] Add dismiss links to `add` form fields [#14](https://github.com/mapbox/team-directory/pull/14)
33 | - [Feature] Add a `add-single` form field [#16](https://github.com/mapbox/team-directory/pull/16)
34 |
35 | ### v1.4.1
36 |
37 | - [UI] Style admin fields differently [#11](https://github.com/mapbox/team-directory/issues/11)
38 |
39 | ### v1.4.0
40 |
41 | - [feature] Add fuzzy filtering [#9](https://github.com/mapbox/team-directory/pull/9)
42 | - [bug] Prolong loading until team data is fetched [#8](https://github.com/mapbox/team-directory/issues/8)
43 |
44 | ### v1.3.1
45 |
46 | - [bug] User form values passed by parent to the form component were not updating as `componentWillReceiveProps` was not called.
47 |
48 | ### v1.3.0
49 |
50 | - [feature] Added `pushState` as an optional flag to enable push state paths. Uses a hash prefix if this is not set.
51 |
52 | ### v1.2.3
53 |
54 | - [bug] Missing basePath on conditioned filtering.
55 |
56 | ### v1.2.2
57 |
58 | - [bug] Added `basePath` to all `reRoute` paths.
59 |
60 | ### v1.2.1
61 |
62 | - [bug] Explicty pass `basePath` to all routes and use a base tag in index.html.
63 |
64 | ### v1.2.0
65 |
66 | - [feature] Added optional `basePath` parameter to support root paths other than `'/'`.
67 |
68 | ### v1.1.0
69 |
70 | - [feature] Added `user.edit` event
71 | - [ui] Loading state on all IO
72 | - [ui] Redirect to listing page after all user operations
73 | - [bug] Pass optional branch param to fetch parameters.
74 |
75 | ### v1.0.5
76 |
77 | - [bug] Fix error on `sorts` dispatching.
78 |
79 | ### v1.0.4
80 |
81 | - [bug] Corrected documentation and function call for `sorts`.
82 |
83 | ### v1.0.3
84 |
85 | - [bug] Corrected documentation and function calls for `listingTemplate` & `statsTemplate`.
86 |
87 | ### v1.0.2
88 |
89 | - [bug] Support branches when reading form & team data contents.
90 |
91 | ### v1.0.1
92 |
93 | - [bug] Deploy first release.
94 |
95 | ### v1.0.0
96 |
97 | - Initial commit.
98 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | ## Developing
2 |
3 | - Clone a copy of this project
4 | - Change into the project directory from the terminal and install dependencies via `npm install`.
5 | - Replace all the credentials found in [index][] with your own. Read [Getting started][] to learn more.
6 | - Run `npm start` to start a server on `http://localhost:9966/`.
7 |
8 | [Getting started]: https://github.com/mapbox/team-directory/blob/master/GETTING_STARTED.md
9 | [index]: https://github.com/mapbox/team-directory/blob/master/index.html
10 |
11 | ## Tests
12 |
13 | To run tests locally, you'll need create a writeable file called `.env.sh` with
14 | the following contents:
15 |
16 | ```sh
17 | export GitHubToken="GITHUB TOKEN"
18 | export account="ACCOUNT NAME"
19 | export repo="REPO NAME"
20 | export team="TEAM FILENAME"
21 | export form="FORM FILENAME"
22 | ```
23 |
24 | _Note:_ you can optionally add `export branch="BRANCH NAME"` if test data lies in a
25 | specific branch.
26 |
27 | Run tests via
28 |
29 | npm run test-local
30 |
31 | ## Deploying
32 |
33 | - `npm run build`
34 | - Add entry to [CHANGELOG](https://github.com/mapbox/team-directory/blob/master/CHANGELOG.md)
35 | - Update the version key in [package.json](https://github.com/mapbox/team-directory/blob/master/package.json#L3)
36 | - Commit and push
37 | - `git tag -a vX.X.X -m 'vX.X.X'`
38 | - `git push --tags`
39 | - `npm publish`
40 |
--------------------------------------------------------------------------------
/GETTING_STARTED.md:
--------------------------------------------------------------------------------
1 | ## Getting started
2 |
3 | #### Table of contents
4 |
5 | - [Quick start](#quick-start)
6 | - [Initializing](#initializing)
7 | - [form.json](#formjson)
8 | - [form.json types](#formjson-types)
9 | - [Advanced configuration](#advanced-configuration)
10 | - [Sorts](#teamdirectorysorts)
11 | - [Validators](#teamdirectoryvalidators)
12 | - [Normalizers](#teamdirectorynormalizers)
13 | - [Listing Template](#teamdirectorylistingtemplate)
14 | - [Stats Template](#teamdirectorystatstemplate)
15 | - [Events](#events)
16 |
17 | ### Quick start
18 |
19 | ```html
20 |
21 |
22 |
23 |
32 | ```
33 |
34 | FIll out the values above with your own credentials. An example configuration
35 | can be found [here](https://github.com/mapbox/team-directory/blob/master/index.html).
36 |
37 | ### Initializing
38 |
39 | ___`TeamDirectory(el, options)`___
40 | - `el` (String or HTMLElement) of the container element the application should populate.
41 | - `options` are as follows:
42 |
43 | | Option | Value | Required | Default value | Description |
44 | | --- | --- | --- | --- | --- |
45 | | GitHubToken | String | ✓ | | A users GitHub token |
46 | | account | String | ✓ | | GitHub organization or account name |
47 | | repo | String | ✓ | | The repository where team & form documents are located |
48 | | team | String | ✓ | | the path and filename in `repo` where team data is written out to |
49 | | form | String | ✓ | | the path and filename in `repo` where form data is read from |
50 | | branch | String | | | Specify a specific branch found in `repo` |
51 | | pushState | Boolean | | `false`| Set to `true` to enable `history.pushstate` Defaults to hash prefixed paths. |
52 | | basePath | String | | `'/'`| Pass an alternate path team-directory should be built from |
53 | | filterKeys | Array | | `['github', 'fname']` | An array of keys as strings that correspond to a key property names found in your form data. |
54 |
55 | #### form.json
56 |
57 | Team Directory is designed for you to provide your own form data to meet your
58 | own needs. An example of a [form.json can be found here](https://github.com/mapbox/team-directory/blob/master/data/form.json) but at it's most basic the structure looks
59 | like this:
60 |
61 | ```json
62 | {
63 | "Basic information": [{
64 | "key": "github",
65 | "label": "Github username",
66 | "required": true
67 | }, {
68 | "key": "birthday",
69 | "label": "Birthday",
70 | "type": "date"
71 | }]
72 | }
73 | ```
74 |
75 | _A couple notes:_
76 |
77 | - __`Basic information`__ is the section name the form field belongs to. You can use any
78 | arbitrary name here or break form fields into any number of sections.
79 | - __GitHub__ is required. This is used in the verification process to insure no
80 | duplicate users is created.
81 |
82 | As you can see from the example json above, each form field is represented
83 | as an object with specific key/value pairings. There are a few as follows:
84 |
85 |
86 | | Option | Value | Required | Description |
87 | | --- | --- | --- | --- |
88 | | key | String | ✓ | A unique key name |
89 | | label | String | | Form label shown above the field element |
90 | | required | Boolean | | If a form field is required the form won't submit for the user until a value has been passed. |
91 | | admin | Boolean | | Form field is only present if the user editing has `admin: true` set in their user object. |
92 | | fields | string | ✓ for some type attributes | Specific to checkboxes and radios, fields are an array of objects with `key` and `label` properties |
93 | | type | string | | If this value isnt provided, it defaults to 'text' See below for form types and their structures |
94 |
95 | #### form.json types
96 |
97 | ##### `add`
98 |
99 | ```json
100 | {
101 | "key": "other-links",
102 | "label": "Other links",
103 | "type": "add"
104 | }
105 | ```
106 |
107 | A form field for adding multiple name/value pairings. Stored as an array of objects
108 | with `name` and `value` properties.
109 |
110 | ##### `add-single`
111 |
112 | ```json
113 | {
114 | "key": "tags",
115 | "label": "Add tags",
116 | "type": "add-single"
117 | }
118 | ```
119 |
120 | A form field for adding multiple values. Stored as an array of strings.
121 |
122 | ##### `select`
123 |
124 | ```json
125 | {
126 | "key": "languages",
127 | "label": "Languages spoken",
128 | "type": "select",
129 | "options": [{
130 | "label": "English",
131 | "key": "en"
132 | }, {
133 | "label": "Español",
134 | "key": "es"
135 | }, {
136 | "label": "German",
137 | "key": "de"
138 | }, {
139 | "label": "French",
140 | "key": "fr"
141 | }]
142 | }
143 | ```
144 |
145 | A autocomplete form field for adding multiple values from a defined list.
146 |
147 | ##### `textarea`
148 |
149 | ```json
150 | {
151 | "key": "adress",
152 | "label": "Home address",
153 | "type": "textarea"
154 | {
155 | ```
156 |
157 | `textarea` input type.
158 |
159 | ##### `checkbox`
160 |
161 | ```json
162 | {
163 | "key": "teams",
164 | "label": "Teams (check all that apply)",
165 | "required": true,
166 | "type": "checkbox",
167 | "fields": [{
168 | "key": "business",
169 | "label": "Business"
170 | }, {
171 | "key": "design",
172 | "label": "Design"
173 | }, {
174 | "key": "engineering",
175 | "label": "Engineering"
176 | }, {
177 | "key": "operations",
178 | "label": "Operations"
179 | }, {
180 | "key": "support",
181 | "label": "Support"
182 | }]
183 | }
184 | ```
185 |
186 | Checkbox input type.
187 |
188 | ##### `radio`
189 |
190 | ```json
191 | {
192 | "key": "office",
193 | "label": "Office",
194 | "type": "radio",
195 | "fields": [{
196 | "key": "dc",
197 | "label": "DC"
198 | }, {
199 | "key": "sf",
200 | "label": "SF"
201 | }, {
202 | "key": "ayacucho",
203 | "label": "Peru"
204 | }, {
205 | "key": "bengaluru",
206 | "label": "India"
207 | }]
208 | }
209 | ```
210 |
211 | Radio input type
212 |
213 | ##### `number`
214 |
215 | ```json
216 | {
217 | "key": "call",
218 | "label": "Mobile number",
219 | "type": "number"
220 | }
221 | ```
222 |
223 | Number input type.
224 |
225 | ##### `date`
226 |
227 | ```json
228 | }
229 | "key": "birthday",
230 | "label": "Birthday",
231 | "type": "date"
232 | }
233 | ```
234 |
235 | Date input type.
236 |
237 | ### Advanced configuration
238 |
239 | If you provide your own custom form data, you'll likely want to override
240 | existing functionality to suit your needs.
241 |
242 | #### `TeamDirectory.sorts`
243 |
244 | Provide your own custom sorting on the listings page. `sorts` should equal an
245 | array of objects with `key` & `sort` pairings. `Key` must must correspond to a
246 | key attribute in the form data and the `sort` function should return the sorted
247 | array when complete.
248 |
249 | ```js
250 | var directory = TeamDirectory(document.getElementById('app'), options);
251 |
252 | directory.sorts([
253 | {
254 | key: 'date',
255 | sort: function(team) {
256 | return team.sort((a, b) => {
257 | return new Date(b.birthday).getTime() - new Date(a.birthday).getTime();
258 | });
259 | }
260 | }, {
261 | key: 'name',
262 | return team.sort((a, b) => {
263 | return a.localeCompare(b);
264 | });
265 | }
266 | ]);
267 | ```
268 |
269 | #### `TeamDirectory.validators`
270 |
271 | Custom validation that's called before a team member is created or updated.
272 | The validators function is passed two arguments: `obj` The team member object &
273 | `callback` A function that's called in your code with either a string messsage
274 | describing a validation error found or `null` (no error found). Team member
275 | data will not be submitted until validation passes.
276 |
277 | ```js
278 | var directory = TeamDirectory(document.getElementById('app'), options);
279 |
280 | directory.validators(function(obj, callback) {
281 | if (obj.office === 'other' && !obj.city) {
282 | return callback('If the office selected is other, please enter your city');
283 | }
284 |
285 | // No validation errors if it gets here
286 | return callback(null);
287 | });
288 | ```
289 |
290 | #### `TeamDirectory.normalizers`
291 |
292 | Format/normalize fields a user before its submitted. The normalizer function is
293 | passed two arguments: `obj` The team member object & `callback` A function
294 | that's called at the end of the function containing the new normalized/formatted
295 | user object. Team member data will not be submitted until this callback is called.
296 |
297 | ```js
298 | var directory = TeamDirectory(document.getElementById('app'), options);
299 |
300 | directory.normalizers(function(obj, callback) {
301 | return callback(obj.map(function(data) {
302 |
303 | // Remove any capitalization from an entered username.
304 | data.username = data.username.toLowerCase();
305 | return data;
306 | });
307 | });
308 | ```
309 |
310 | #### `TeamDirectory.listingTemplate`
311 |
312 | Create a custom listing template for team members. The listingTemplate is a
313 | function passed one argument: `obj` the current user in a list drawn out to
314 | the main page. The function must return [jsx template](https://facebook.github.io/jsx/).
315 |
316 | ```js
317 | var directory = TeamDirectory(document.getElementById('app'), options);
318 |
319 | directory.listingTemplate(function(obj) {
320 | var fullName = obj.fname + ' ' + obj.lname;
321 |
322 | return (
323 |
324 | {fullName}
325 | {obj.birthday}
326 |
327 | );
328 | });
329 | ```
330 |
331 | #### `TeamDirectory.statsTemplate`
332 |
333 | Evaluate team user data and present a template of found statistics. The
334 | statsTemplate is passed one argument: `team` the team array of users. The
335 | function must return [jsx template](https://facebook.github.io/jsx/). If no
336 | statsTemplate is provided, the teamStats link and modal will not be present
337 | on the listing page.
338 |
339 | ```js
340 | var directory = TeamDirectory(document.getElementById('app'), options);
341 |
342 | directory.statsTemplate(function(team) {
343 | var length = team.length;
344 | var phones = team.filter(function(member) {
345 | return member.phone;
346 | }).length;
347 |
348 | return (
349 |
350 |
Team stats
351 |
There are {length} total team members and {phones} have phones.
352 |
353 | );
354 | });
355 | ```
356 |
357 | ### Events
358 |
359 | ___`TeamDirectory.on(type, function)`___
360 |
361 | Clients can subscribe to events that happen in the application.
362 |
363 | ```js
364 | var directory = TeamDirectory(document.getElementById('app'), options);
365 |
366 | // Get team data when it's available on the page
367 | directory.on('load', function(ev) {
368 | console.log(ev.team);
369 | });
370 | ```
371 |
372 | Available types are as follows:
373 |
374 | - `load`
375 | - `user.created`
376 | - `user.editing`
377 | - `user.updated`
378 | - `user.removed`
379 |
--------------------------------------------------------------------------------
/LICENCE:
--------------------------------------------------------------------------------
1 | ISC License
2 |
3 | Copyright (c) 2015, Mapbox
4 |
5 | Permission to use, copy, modify, and/or distribute this software for any
6 | purpose with or without fee is hereby granted, provided that the above
7 | copyright notice and this permission notice appear in all copies.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
16 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Team directory
2 | ---
3 |
4 | [](https://npmjs.org/package/team-directory) [](https://circleci.com/gh/mapbox/team-directory)
5 |
6 | A read/write interface for team data managed on GitHub.
7 |
8 | 
9 |
10 | ### Features
11 |
12 | - List filtering/sorting with stored params
13 | - [vCard](https://en.wikipedia.org/wiki/VCard) downloads (single or entire team)
14 | - Stats display based on team data
15 | - User/admin only editing access
16 |
17 | ##### Customization
18 |
19 | - Your own data. Provide a json document of form field objects and team
20 | directory renders each one into submittable fields for each user.
21 | - Listing display. Limit fields that are displayed on the main listing by opting
22 | for which ones are used in a template.
23 | - Stats display
24 | - Sorting
25 | - List filtering
26 | - Form validation
27 | - Value normalization
28 |
29 | ##### Light admin access
30 |
31 | If an admin key is set to true on a user they are granted additional features:
32 |
33 | - View/edit all users
34 | - Download all fields from the team list as a CSV document.
35 |
36 | ---
37 |
38 | ## ☛ [Getting started][]
39 | ## ☛ [Contributing][]
40 |
41 | [Contributing]: https://github.com/mapbox/team-directory/blob/master/CONTRIBUTING.md
42 | [Getting started]: https://github.com/mapbox/team-directory/blob/master/GETTING_STARTED.md
43 | [index]: https://github.com/mapbox/team-directory/blob/master/index.html
44 |
--------------------------------------------------------------------------------
/circle.yml:
--------------------------------------------------------------------------------
1 | general:
2 | branches:
3 | ignore:
4 | - test-data
5 |
--------------------------------------------------------------------------------
/css/base.css:
--------------------------------------------------------------------------------
1 | /*
2 | Reset ♥
3 | http://meyerweb.com/eric/tools/css/reset/
4 | v2.0 | 20110126
5 | License: none (public domain)
6 | ------------------------------------------------------- */
7 | html, body, div, span, applet, object, iframe,
8 | h1, h2, h3, h4, h5, h6, p, blockquote, pre,
9 | a, abbr, acronym, address, big, cite, code,
10 | del, dfn, em, img, ins, kbd, q, s, samp,
11 | small, strike, strong, sub, sup, tt, var,
12 | b, u, i, center,
13 | dl, dt, dd, ol, ul, li,
14 | fieldset, form, label, legend,
15 | table, caption, tbody, tfoot, thead, tr, th, td,
16 | article, aside, canvas, details, embed,
17 | figure, figcaption, footer, header, hgroup,
18 | menu, nav, output, ruby, section, summary,
19 | time, mark, audio, video {
20 | margin:0;
21 | padding:0;
22 | border:0;
23 | font-size:100%;
24 | font:inherit;
25 | vertical-align:baseline;
26 | }
27 | /* HTML5 display-role reset for older browsers */
28 | article, aside, details, figcaption, figure,
29 | footer, header, hgroup, menu, nav, section {
30 | display:block;
31 | }
32 | body { line-height:1; }
33 | ol, ul { list-style:none; }
34 | blockquote, q { quotes:none; }
35 | blockquote:before, blockquote:after,
36 | q:before, q:after { content:''; content:none; }
37 | /* tables still need 'cellspacing="0"' in the markup */
38 | table { border-collapse: collapse; border-spacing:0; }
39 | /* remember to define focus styles. Hee Haw */
40 | :focus { outline:0; }
41 |
42 | *, *:after, *:before {
43 | -webkit-box-sizing:border-box;
44 | -moz-box-sizing:border-box;
45 | box-sizing:border-box;
46 | }
47 |
48 | /* Inline Elements: Defaults
49 | ------------------------------------------------------- */
50 | body,
51 | input,
52 | textarea {
53 | color:#404040;
54 | color:rgba(0,0,0,0.75);
55 | font:16px/24px 'Open sans', 'Helvetica neue', Helvetica, sans-serif;
56 | -webkit-font-smoothing:antialiased;
57 | }
58 |
59 | body { background:#fff; }
60 | .dark { color:#fff; }
61 |
62 | h1,
63 | h2,
64 | h3,
65 | h4,
66 | h5,
67 | h6 {
68 | font-family:'Open Sans Bold', 'Helvetica neue', Helvetica, sans-serif;
69 | font-weight:normal;
70 | margin:0;
71 | }
72 |
73 | h1 {
74 | font-size:30px;
75 | line-height:40px;
76 | }
77 | h2 {
78 | font-size:22px;
79 | line-height:30px;
80 | padding-top: 5px;
81 | padding-bottom:5px;
82 | }
83 |
84 | h3 {
85 | font-size:15px;
86 | line-height:20px;
87 | }
88 |
89 | h4, h5, h6 {
90 | font-size:12px;
91 | line-height:20px;
92 | }
93 |
94 | p { margin-bottom:20px; }
95 |
96 | p:last-child { margin-bottom:0; }
97 |
98 | small,
99 | .small {
100 | font-size:12px;
101 | line-height:20px;
102 | letter-spacing:0;
103 | }
104 |
105 | small { display:block; }
106 |
107 | .strong,
108 | strong {
109 | font-weight:700;
110 | }
111 |
112 | /* links */
113 | a,
114 | a code {
115 | color:#3887BE;
116 | text-decoration:none;
117 | }
118 | a:hover code,
119 | a:hover { color:#63b6e5; }
120 | a:focus {
121 | -webkit-box-shadow:inset 0 0 0 1px rgba(0,0,0,0.05);
122 | box-shadow:inset 0 0 0 1px rgba(0,0,0,0.05);
123 | }
124 |
125 | .dark a,
126 | a.dark,
127 | .dark a > code { color:rgba(255,255,255,.5); }
128 | a.dark.active,
129 | .dark a.active,
130 | a.dark:hover,
131 | .dark a:hover > code,
132 | .dark a:hover { color:white; }
133 | .dark a:focus {
134 | box-shadow:inset 0 0 0 1px rgba(255,255,255,0.05);
135 | }
136 |
137 | a.quiet { color:rgba(0,0,0,0.5); }
138 | a.quiet.active, a.quiet:hover { color:rgba(0,0,0,0.75); }
139 |
140 | /* Buttons */
141 | button,
142 | .button,
143 | [type=button],
144 | [type=submit] {
145 | background-color:#3887be;
146 | text-align:center;
147 | color:#fff;
148 | display:inline-block;
149 | height:40px;
150 | margin:0px;
151 | padding:10px;
152 | position:relative;
153 | border:none;
154 | cursor:pointer;
155 | border-radius:3px;
156 | white-space:nowrap;
157 | text-overflow:ellipsis;
158 | /* Protects button metrics in .prose context */
159 | line-height:20px;
160 | font-size:12px;
161 | -webkit-appearance:none;
162 | font-weight:700;
163 |
164 | /* The button element needs to be forced this */
165 | -webkit-font-smoothing:antialiased;
166 | }
167 |
168 | button:hover,
169 | .button:hover,
170 | .button.active,
171 | [type=button]:hover,
172 | [type=submit]:hover {
173 | color:#fff;
174 | }
175 |
176 | button:focus,
177 | .button:focus,
178 | [type=submit]:focus,
179 | [type=button]:focus {
180 | -webkit-box-shadow: inset 0 0 0 3px rgba(0,0,0,0.1);
181 | box-shadow: inset 0 0 0 3px rgba(0,0,0,0.1);
182 | }
183 |
184 | button:hover,
185 | .button:hover,
186 | .button.active,
187 | [type=submit]:hover,
188 | [type=button]:hover {
189 | background-color:#52a1d8;
190 | }
191 | button.button-secondary,
192 | .button.button-secondary {
193 | background-color:#8889cc;
194 | }
195 | button.button-secondary:hover,
196 | .button.button-secondary.active,
197 | .button.button-secondary:hover {
198 | background-color:#a4a4e5;
199 | }
200 |
201 | .button.disabled,
202 | .button:disabled,
203 | button.disabled,
204 | button:disabled,
205 | [type=submit]:disabled,
206 | [type=button]:disabled {
207 | background:transparent;
208 | border:1px dashed rgba(0,0,0,0.25);
209 | color:rgba(0,0,0,0.25);
210 | cursor:not-allowed;
211 | }
212 |
213 | .button.disabled:hover,
214 | .button:disabled:hover,
215 | button.disabled:hover,
216 | button:disabled:hover,
217 | [type=submit]:disabled:hover,
218 | [type=button]:disabled:hover {
219 | background:transparent;
220 | }
221 |
222 | .button.disabled:focus,
223 | .button:disabled:focus,
224 | button.disabled:focus,
225 | button:disabled:focus,
226 | [type=submit]:disabled:focus,
227 | [type=button]:disabled:focus {
228 | box-shadow:none;
229 | }
230 |
231 | .button.short {
232 | height:30px;
233 | padding-top:5px;
234 | padding-bottom:5px;
235 | vertical-align:middle;
236 | }
237 |
238 | /* Pill wrapper for joining buttons. */
239 | /* Use to eliminate whitespace between buttons. */
240 | .pill{
241 | display:inline-block;
242 | }
243 | .pill > * {
244 | border-radius:0 0 0 0;
245 | border:0 solid white;
246 | border-left-width:1px;
247 | }
248 | .pill > *:first-of-type,
249 | .pill > *:first-child {
250 | border-radius:3px 0 0 3px;
251 | border-left-width:0;
252 | }
253 | .pill > *:last-of-type,
254 | .pill > *:last-child {
255 | border-radius:0 3px 3px 0;
256 | }
257 |
258 | /* Vertical pill if pill contents is full width . */
259 | .pill > .col12 {
260 | border-bottom-width:1px;
261 | border-left-width:0;
262 | }
263 | .pill > .col12:first-child {
264 | border-radius:3px 3px 0 0;
265 | }
266 | .pill > .col12:last-child {
267 | border-radius:0 0 3px 3px;
268 | border-bottom:none;
269 | }
270 | .pill > .col12:only-child {
271 | border-radius: 3px;
272 | }
273 |
274 | .pill input[type=text] {
275 | border:1px solid rgba(0,0,0,0.1);
276 | border-radius:0;
277 | }
278 |
279 | .pill input[type=text]:focus {
280 | border:1px solid rgba(0,0,0,0.25);
281 | }
282 |
283 | /* Checkbox pill - use in conjunction with pill + button
284 | -------------------------------------------------- */
285 | .checkbox-pill input[type=checkbox] { display: none;}
286 | .checkbox-pill input[type=checkbox] + *:before { background-color: rgba(0,0,0,0.25); border-radius: 2px;}
287 | .checkbox-pill input[type=checkbox]:not(:checked) + *:before { content: '';}
288 | .checkbox-pill input[type=checkbox]:checked + .button { background-color: #52a1d8;}
289 | .checkbox-pill input[type=checkbox]:checked + .button.quiet { background-color: rgba(0,0,0,.5);}
290 | .checkbox-pill input[type=checkbox]:checked + .button.loud { background-color: #a4a4e5;}
291 | .dark.checkbox-pill input[type=checkbox]:checked + .button.quiet,
292 | .dark .checkbox-pill input[type=checkbox]:checked + .button.quiet { background-color: rgba(255,255,255,0.25);}
293 |
294 | /* Radio pill - use in conjunction with pill + button
295 | -------------------------------------------------- */
296 | .radio-pill input[type=radio] { display: none;}
297 | .radio-pill input[type=radio] + .button:before { overflow: hidden;}
298 | .radio-pill input[type=radio]:not(:checked) + .button:before { width: 0;}
299 | .radio-pill input[type=radio]:checked + .button { background-color: #52a1d8;}
300 | .radio-pill input[type=radio]:checked + .button.quiet { background-color: rgba(0,0,0,.5);}
301 | .radio-pill input[type=radio]:checked + .button.loud { background-color: #a4a4e5;}
302 | .dark.radio-pill input[type=radio]:checked + .button.quiet,
303 | .dark .radio-pill input[type=radio]:checked + .button.quiet { background-color: rgba(255,255,255,0.25);}
304 |
305 | /* Rounded toggle
306 | -------------------------------------------------- */
307 | .rounded-toggle {
308 | margin-top:5px;
309 | margin-bottom:5px;
310 | padding:2px;
311 | border-radius:15px;
312 | vertical-align:middle;
313 | background:rgba(0,0,0,.1)
314 | }
315 |
316 | .dark .rounded-toggle { background: rgba(255,255,255,.1);}
317 | .rounded-toggle > * {
318 | cursor:pointer;
319 | vertical-align:top;
320 | display:inline-block;
321 | border-radius:16px;
322 | padding:3px 10px;
323 | font-size:12px;
324 | color:rgba(0,0,0,0.5);
325 | line-height:20px;
326 | }
327 |
328 | .rounded-toggle > *:hover { color: rgba(0,0,0,0.75); }
329 | .rounded-toggle input[type=radio] { display:none; }
330 | .rounded-toggle input[type=radio]:checked + label,
331 | .rounded-toggle .active { background: white; color: rgba(0,0,0,.5); }
332 |
333 | form fieldset {
334 | margin: 0 0 20px;
335 | }
336 | form fieldset:last-child {
337 | margin-bottom: 0;
338 | }
339 |
340 | form fieldset label {
341 | font:12px/20px 'Open Sans Bold', sans-serif;
342 | display:block;
343 | margin:0 0 5px;
344 | }
345 |
346 | form fieldset label.inline { margin-bottom: 0;}
347 | input,
348 | select,
349 | button {
350 | vertical-align:top;
351 | }
352 | textarea,
353 | input[type=password],
354 | input[type=text],
355 | input[type=date],
356 | input[type=email],
357 | input[type=number] {
358 | background-color:#fff;
359 | border:1px solid rgba(0,0,0,0.1);
360 | display:inline-block;
361 | height:40px;
362 | margin:0;
363 | color:rgba(0,0,0,.5);
364 | padding:10px;
365 | border-radius:0;
366 | -webkit-appearance:none;
367 | -webkit-transition:border-color .05s;
368 | -moz-transition:border-color .05s;
369 | -ms-transition:border-color .05s;
370 | transition:border-color .05s;
371 | }
372 | textarea:focus,
373 | input[type=password]:focus,
374 | input[type=text]:focus,
375 | input[type=date]:focus,
376 | input[type=email]:focus,
377 | input[type=number]:focus {
378 | outline:thin dotted\8; /* ie8 below */
379 | border-color:rgba(0,0,0,0.25);
380 | color:#404040;
381 | }
382 |
383 | textarea {
384 | height:80px;
385 | max-width:none;
386 | overflow:auto;
387 | resize:none;
388 | }
389 |
390 | /* Special Form Components */
391 | .with-icon {
392 | position:relative;
393 | }
394 | .with-icon input[type=text],
395 | .with-icon input[type=number] {
396 | padding-left:35px;
397 | }
398 | .with-icon span.icon {
399 | position:absolute;
400 | top:10px;
401 | left:10px;
402 | }
403 |
404 | /* Checkboxes */
405 | .checkbox input[type=checkbox] { display:none; }
406 | .checkbox label {
407 | display:block;
408 | cursor:pointer;
409 | padding:10px;
410 | }
411 |
412 | /* Icons
413 | ------------------------------------------------------- */
414 | @font-face {
415 | font-family:'icon';
416 | src:url('icon.eot?v=19');
417 | src:url('icon.eot?v=19#iefix') format('embedded-opentype'),
418 | url('icon.woff?v=19') format('woff');
419 | font-weight:normal;
420 | font-style:normal;
421 | }
422 |
423 | .icon:before {
424 | font-family: 'icon';
425 | content:'';
426 | display:inline-block;
427 | width:20px;
428 | height:20px;
429 | font-size: 20px;
430 | color: inherit;
431 | vertical-align:top;
432 | -webkit-background-size:4320px 60px;
433 | background-size:4320px 60px;
434 | speak: none;
435 | font-style: normal;
436 | font-weight: normal;
437 | font-variant: normal;
438 | text-transform: none;
439 | line-height: 1;
440 | /* Better Font Rendering =========== */
441 | -webkit-font-smoothing: antialiased;
442 | -moz-osx-font-smoothing: grayscale;
443 | }
444 | .icon:before {
445 | margin-right:5px;
446 | }
447 | .icon.caret-down:before {
448 | content: "\e6a5";
449 | }
450 | .icon.caret-left:before {
451 | content: "\e6a6";
452 | }
453 | .icon.caret-right:before {
454 | content: "\e6a7";
455 | }
456 | .icon.caret-up:before {
457 | content: "\e6a8";
458 | }
459 | .icon.step-ramp:before {
460 | content: "\e69c";
461 | }
462 | .icon.smooth-ramp:before {
463 | content: "\e69d";
464 | }
465 | .icon.bug:before {
466 | content: "\e69e";
467 | }
468 | .icon.bell:before {
469 | content: "\e69f";
470 | }
471 | .icon.polygon:before {
472 | content: "\e600";
473 | }
474 | .icon.minus-document:before {
475 | content: "\e601";
476 | }
477 | .icon.plus-document:before {
478 | content: "\e602";
479 | }
480 | .icon.check-document:before {
481 | content: "\e603";
482 | }
483 | .icon.grid:before {
484 | content: "\e604";
485 | }
486 | .icon.osm:before {
487 | content: "\e605";
488 | }
489 | .icon.at:before {
490 | content: "\e606";
491 | }
492 | .icon.history:before {
493 | content: "\e607";
494 | }
495 | .icon.palette:before {
496 | content: "\e608";
497 | }
498 | .icon.sun:before {
499 | content: "\e609";
500 | }
501 | .icon.account:before {
502 | content: "\e60a";
503 | }
504 | .icon.adjust-stroke:before {
505 | content: "\e60b";
506 | }
507 | .icon.alert:before {
508 | content: "\e60c";
509 | }
510 | .icon.android:before {
511 | content: "\e60d";
512 | }
513 | .icon.antialias:before {
514 | content: "\e60e";
515 | }
516 | .icon.apple:before {
517 | content: "\e60f";
518 | }
519 | .icon.arrive:before {
520 | content: "\e610";
521 | }
522 | .icon.arrowright:before {
523 | content: "\e611";
524 | }
525 | .icon.bear-left:before {
526 | content: "\e612";
527 | }
528 | .icon.bear-right:before {
529 | content: "\e613";
530 | }
531 | .icon.bolt:before {
532 | content: "\e614";
533 | }
534 | .icon.book:before {
535 | content: "\e615";
536 | }
537 | .icon.bookmark:before {
538 | content: "\e616";
539 | }
540 | .icon.brackets:before {
541 | content: "\e617";
542 | }
543 | .icon.building:before {
544 | content: "\e618";
545 | }
546 | .icon.cap-butt:before {
547 | content: "\e619";
548 | }
549 | .icon.cap-round:before {
550 | content: "\e61a";
551 | }
552 | .icon.cap-square:before {
553 | content: "\e61b";
554 | }
555 | .icon.cart:before {
556 | content: "\e61c";
557 | }
558 | .icon.check:before {
559 | content: "\e61d";
560 | }
561 | .icon.clipboard:before {
562 | content: "\e61e";
563 | }
564 | .icon.close:before {
565 | content: "\e61f";
566 | }
567 | .icon.x:before {
568 | content: "\e61f";
569 | }
570 | .icon.cloud:before {
571 | content: "\e620";
572 | }
573 | .icon.compass:before {
574 | content: "\e621";
575 | }
576 | .icon.contact:before {
577 | content: "\e622";
578 | }
579 | .icon.creditcard:before {
580 | content: "\e623";
581 | }
582 | .icon.cross-edge:before {
583 | content: "\e624";
584 | }
585 | .icon.crosshair:before {
586 | content: "\e625";
587 | }
588 | .icon.dasharray:before {
589 | content: "\e626";
590 | }
591 | .icon.dashboard:before {
592 | content: "\e627";
593 | }
594 | .icon.data:before {
595 | content: "\e628";
596 | }
597 | .icon.depart:before {
598 | content: "\e629";
599 | }
600 | .icon.document:before {
601 | content: "\e62a";
602 | }
603 | .icon.down:before {
604 | content: "\e62b";
605 | }
606 | .icon.duplicate:before {
607 | content: "\e62c";
608 | }
609 | .icon.en:before {
610 | content: "\e62d";
611 | }
612 | .icon.enter-roundabout:before {
613 | content: "\e62e";
614 | }
615 | .icon.eye:before {
616 | content: "\e62f";
617 | }
618 | .icon.facebook:before {
619 | content: "\e630";
620 | }
621 | .icon.floppy:before {
622 | content: "\e631";
623 | }
624 | .icon.folder:before {
625 | content: "\e632";
626 | }
627 | .icon.nofolder:before {
628 | content: "\e6a9";
629 | }
630 | .icon.font:before {
631 | content: "\e633";
632 | }
633 | .icon.forward:before {
634 | content: "\e634";
635 | }
636 | .icon.foursquare:before {
637 | content: "\e635";
638 | }
639 | .icon.fullscreen:before {
640 | content: "\e636";
641 | }
642 | .icon.gap-width:before {
643 | content: "\e637";
644 | }
645 | .icon.github:before {
646 | content: "\e638";
647 | }
648 | .icon.gl:before {
649 | content: "\e639";
650 | }
651 | .icon.globe:before {
652 | content: "\e63a";
653 | }
654 | .icon.graph:before {
655 | content: "\e63b";
656 | }
657 | .icon.hand:before {
658 | content: "\e63c";
659 | }
660 | .icon.harddrive:before {
661 | content: "\e63d";
662 | }
663 | .icon.heart:before {
664 | content: "\e63e";
665 | }
666 | .icon.help:before {
667 | content: "\e63f";
668 | }
669 | .icon.home:before {
670 | content: "\e640";
671 | }
672 | .icon.info:before {
673 | content: "\e641";
674 | }
675 | .icon.inspect:before {
676 | content: "\e642";
677 | }
678 | .icon.join-miter:before {
679 | content: "\e643";
680 | }
681 | .icon.join-bevel:before {
682 | content: "\e644";
683 | }
684 | .icon.join-round:before {
685 | content: "\e645";
686 | }
687 | .icon.l-r-arrow:before {
688 | content: "\e646";
689 | }
690 | .icon.land:before {
691 | content: "\e647";
692 | }
693 | .icon.landuse:before {
694 | content: "\e648";
695 | }
696 | .icon.layers:before {
697 | content: "\e649";
698 | }
699 | .icon.leaflet:before {
700 | content: "\e64a";
701 | }
702 | .icon.levels:before {
703 | content: "\e64b";
704 | }
705 | .icon.lifebuoy:before {
706 | content: "\e64c";
707 | }
708 | .icon.line-miter-limit:before {
709 | content: "\e64d";
710 | }
711 | .icon.line-round-limit:before {
712 | content: "\e64e";
713 | }
714 | .icon.link:before {
715 | content: "\e64f";
716 | }
717 | .icon.linkedin:before {
718 | content: "\e650";
719 | }
720 | .icon.lock:before {
721 | content: "\e651";
722 | }
723 | .icon.logout:before {
724 | content: "\e652";
725 | }
726 | .icon.mail:before {
727 | content: "\e653";
728 | }
729 | .icon.mapbox:before {
730 | content: "\e654";
731 | }
732 | .icon.marker:before {
733 | content: "\e655";
734 | }
735 | .icon.icon-halo:before {
736 | content: "\e6a2";
737 | }
738 | .icon.symbol-layer:before {
739 | content: "\e6aa";
740 | }
741 | .icon.max-text-angle:before {
742 | content: "\e656";
743 | }
744 | .icon.menu:before {
745 | content: "\e657";
746 | }
747 | .icon.minus:before {
748 | content: "\e658";
749 | }
750 | .icon.mobile:before {
751 | content: "\e659";
752 | }
753 | .icon.mt:before {
754 | content: "\e65a";
755 | }
756 | .icon.next:before {
757 | content: "\e65b";
758 | }
759 | .icon.noeye:before {
760 | content: "\e65c";
761 | }
762 | .icon.opacity:before {
763 | content: "\e65d";
764 | }
765 | .icon.package:before {
766 | content: "\e65e";
767 | }
768 | .icon.paint:before {
769 | content: "\e65f";
770 | }
771 | .icon.pencil:before {
772 | content: "\e660";
773 | }
774 | .icon.picture:before {
775 | content: "\e661";
776 | }
777 | .icon.plus:before {
778 | content: "\e662";
779 | }
780 | .icon.point-line:before {
781 | content: "\e663";
782 | }
783 | .icon.point:before {
784 | content: "\e664";
785 | }
786 | .icon.polyline:before {
787 | content: "\e665";
788 | }
789 | .icon.prev:before {
790 | content: "\e666";
791 | }
792 | .icon.printer:before {
793 | content: "\e667";
794 | }
795 | .icon.raster:before {
796 | content: "\e668";
797 | }
798 | .icon.redo:before {
799 | content: "\e669";
800 | }
801 | .icon.refresh:before {
802 | content: "\e66a";
803 | }
804 | .icon.return:before {
805 | content: "\e66b";
806 | }
807 | .icon.rotate:before {
808 | content: "\e66c";
809 | }
810 | .icon.rss:before {
811 | content: "\e66d";
812 | }
813 | .icon.satellite:before {
814 | content: "\e66e";
815 | }
816 | .icon.search:before {
817 | content: "\e66f";
818 | }
819 | .icon.share:before {
820 | content: "\e670";
821 | }
822 | .icon.sharp-left:before {
823 | content: "\e671";
824 | }
825 | .icon.sharp-right:before {
826 | content: "\e672";
827 | }
828 | .icon.sprocket:before {
829 | content: "\e673";
830 | }
831 | .icon.stackoverflow:before {
832 | content: "\e674";
833 | }
834 | .icon.star:before {
835 | content: "\e675";
836 | }
837 | .icon.street:before {
838 | content: "\e676";
839 | }
840 | .icon.text-align-bottom-center:before {
841 | content: "\e677";
842 | }
843 | .icon.text-align-bottom-left:before {
844 | content: "\e678";
845 | }
846 | .icon.text-align-bottom-right:before {
847 | content: "\e679";
848 | }
849 | .icon.text-align-center-center:before {
850 | content: "\e67a";
851 | }
852 | .icon.text-align-center-left:before {
853 | content: "\e67b";
854 | }
855 | .icon.text-align-center-right:before {
856 | content: "\e67c";
857 | }
858 | .icon.text-align-overlap:before {
859 | content: "\e67d";
860 | }
861 | .icon.text-align-top-center:before {
862 | content: "\e67e";
863 | }
864 | .icon.text-align-top-left:before {
865 | content: "\e67f";
866 | }
867 | .icon.text-align-top-right:before {
868 | content: "\e680";
869 | }
870 | .icon.text-halo:before {
871 | content: "\e6a3";
872 | }
873 | .icon.text-halo-width:before {
874 | content: "\e681";
875 | }
876 | .icon.text-ignore-placement:before {
877 | content: "\e682";
878 | }
879 | .icon.text-justify-center:before {
880 | content: "\e683";
881 | }
882 | .icon.text-justify-left:before {
883 | content: "\e684";
884 | }
885 | .icon.text-justify-right:before {
886 | content: "\e685";
887 | }
888 | .icon.text-letter-spacing:before {
889 | content: "\e686";
890 | }
891 | .icon.text-line-height:before {
892 | content: "\e687";
893 | }
894 | .icon.text-max-width:before {
895 | content: "\e688";
896 | }
897 | .icon.text-padding:before {
898 | content: "\e689";
899 | }
900 | .icon.text-rotate:before {
901 | content: "\e68a";
902 | }
903 | .icon.text-size:before {
904 | content: "\e68b";
905 | }
906 | .icon.tilemill:before {
907 | content: "\e68c";
908 | }
909 | .icon.time:before {
910 | content: "\e68d";
911 | }
912 | .icon.tooltip:before {
913 | content: "\e68e";
914 | }
915 | .icon.transform-lowercase:before {
916 | content: "\e68f";
917 | }
918 | .icon.transform-uppercase:before {
919 | content: "\e690";
920 | }
921 | .icon.trash:before {
922 | content: "\e691";
923 | }
924 | .icon.position:before {
925 | content: "\e6a4";
926 | }
927 | .icon.turn-left:before {
928 | content: "\e692";
929 | }
930 | .icon.turn-right:before {
931 | content: "\e693";
932 | }
933 | .icon.twitter:before {
934 | content: "\e694";
935 | }
936 | .icon.tx:before {
937 | content: "\e695";
938 | }
939 | .icon.u-d-arrow:before {
940 | content: "\e696";
941 | }
942 | .icon.u-turn:before {
943 | content: "\e697";
944 | }
945 | .icon.undo:before {
946 | content: "\e698";
947 | }
948 | .icon.up:before {
949 | content: "\e699";
950 | }
951 | .icon.video:before {
952 | content: "\e69a";
953 | }
954 | .icon.water:before {
955 | content: "\e69b";
956 | }
957 | .icon.quotes:before {
958 | content: "\e6a0";
959 | }
960 | .icon.number:before {
961 | content: "\e6a1";
962 | }
963 | .icon.line:before {
964 | content: "\e6ab";
965 | }
966 | .icon.fill:before {
967 | content: "\e6ac";
968 | }
969 |
970 | /* Columns
971 | ------------------------------------------------------- */
972 | .limiter {
973 | width:83.33%;
974 | max-width:1000px;
975 | margin-left:auto;
976 | margin-right:auto;
977 | }
978 |
979 | .col0 { float:left; width:04.1666%; }
980 | .col1 { float:left; width:08.3333%; }
981 | .col2 { float:left; width:16.6666%; }
982 | .col3 { float:left; width:25.0000%; }
983 | .col4 { float:left; width:33.3333%; }
984 | .col5 { float:left; width:41.6666%; }
985 | .col6 { float:left; width:50.0000%; }
986 | .col7 { float:left; width:58.3333%; }
987 | .col8 { float:left; width:66.6666%; }
988 | .col9 { float:left; width:75.0000%; }
989 | .col10 { float:left; width:83.3333%; }
990 | .col11 { float:left; width:91.6666%; }
991 | .col12 { width:100%; display:block; }
992 |
993 | .margin0 { margin-left:04.1666%; }
994 | .margin1 { margin-left:08.3333%; }
995 | .margin2 { margin-left:16.6666%; }
996 | .margin3 { margin-left:25.0000%; }
997 | .margin4 { margin-left:33.3333%; }
998 | .margin5 { margin-left:41.6666%; }
999 | .margin6 { margin-left:50.0000%; }
1000 | .margin7 { margin-left:58.3333%; }
1001 | .margin8 { margin-left:66.6666%; }
1002 | .margin9 { margin-left:75.0000%; }
1003 | .margin10 { margin-left:83.3333%; }
1004 | .margin11 { margin-left:91.6666%; }
1005 | .margin12 { margin-left:100.0000%; }
1006 |
1007 | /* reverse margins on right-floated elements */
1008 | .margin0r { margin-right:04.1666%; }
1009 | .margin1r { margin-right:08.3333%; }
1010 | .margin2r { margin-right:16.6666%; }
1011 | .margin3r { margin-right:25.0000%; }
1012 | .margin4r { margin-right:33.3333%; }
1013 | .margin5r { margin-right:41.6666%; }
1014 | .margin6r { margin-right:50.0000%; }
1015 | .margin7r { margin-right:58.3333%; }
1016 | .margin8r { margin-right:66.6666%; }
1017 | .margin9r { margin-right:75.0000%; }
1018 | .margin10r { margin-right:83.3333%; }
1019 | .margin11r { margin-right:91.6666%; }
1020 | .margin12r { margin-right:100.0000%; }
1021 |
1022 | .row0 { height: 0px;}
1023 | .row1 { height: 40px;}
1024 | .row2 { height: 80px;}
1025 | .row3 { height: 120px;}
1026 | .row4 { height: 160px;}
1027 | .row5 { height: 200px;}
1028 | .row6 { height: 240px;}
1029 | .row7 { height: 280px;}
1030 | .row8 { height: 320px;}
1031 | .row9 { height: 360px;}
1032 | .row10 { height: 400px;}
1033 | .row11 { height: 440px;}
1034 | .row12 { height: 480px;}
1035 | .row13 { height: 520px;}
1036 | .row14 { height: 560px;}
1037 | .row15 { height: 600px;}
1038 | .row16 { height: 640px;}
1039 |
1040 | /* Padding
1041 | ------------------------------------------------------- */
1042 | .pad0 { padding:5px; }
1043 | .pad1 { padding:10px; }
1044 | .pad2 { padding:20px; }
1045 | .pad4 { padding:40px; }
1046 | .pad8 { padding:80px; }
1047 |
1048 | .pad0x { padding-left: 5px; padding-right: 5px;}
1049 | .pad1x { padding-left: 10px; padding-right: 10px;}
1050 | .pad2x { padding-left: 20px; padding-right: 20px;}
1051 | .pad4x { padding-left: 40px; padding-right: 40px;}
1052 | .pad8x { padding-left: 80px; padding-right: 80px;}
1053 |
1054 | .pad0y { padding-top: 5px; padding-bottom: 5px;}
1055 | .pad1y { padding-top: 10px; padding-bottom: 10px;}
1056 | .pad2y { padding-top: 20px; padding-bottom: 20px;}
1057 | .pad4y { padding-top: 40px; padding-bottom: 40px;}
1058 | .pad8y { padding-top: 80px; padding-bottom: 80px;}
1059 |
1060 | /* Keylines
1061 | ------------------------------------------------------- */
1062 | .keyline-all { border:1px solid rgba(0,0,0,0.10); }
1063 | .keyline-top { border-top:1px solid rgba(0,0,0,0.10); }
1064 | .keyline-right { border-right:1px solid rgba(0,0,0,0.10); }
1065 | .keyline-bottom { border-bottom:1px solid rgba(0,0,0,0.10); }
1066 | .keyline-left { border-left:1px solid rgba(0,0,0,0.10); }
1067 |
1068 | /* Pinned containers
1069 | ------------------------------------------------------- */
1070 | .pin-top,
1071 | .pin-right,
1072 | .pin-bottom,
1073 | .pin-left,
1074 | .pin-topleft,
1075 | .pin-topright,
1076 | .pin-bottomleft,
1077 | .pin-bottomright {
1078 | position:absolute;
1079 | }
1080 | .pin-bottom { right:0; bottom:0; left:0; }
1081 | .pin-top { top:0; right:0; left:0; }
1082 | .pin-left { top:0; bottom:0; left:0; }
1083 | .pin-right { top:0; right:0; bottom:0; }
1084 | .pin-bottomright { bottom:0; right:0; }
1085 | .pin-bottomleft { bottom:0; left:0; }
1086 | .pin-topright { top:0; right:0; }
1087 | .pin-topleft { top:0; left:0; }
1088 |
1089 | /* Fixed containers
1090 | ------------------------------------------------------- */
1091 | .fixed-top,
1092 | .fixed-right,
1093 | .fixed-bottom,
1094 | .fixed-left,
1095 | .fixed-topleft,
1096 | .fixed-topright,
1097 | .fixed-bottomleft,
1098 | .fixed-bottomright {
1099 | position:fixed;
1100 | }
1101 | .fixed-bottom { right:0; bottom:0; left:0; }
1102 | .fixed-top { top:0; right:0; left:0; }
1103 | .fixed-left { top:0; bottom:0; left:0; }
1104 | .fixed-right { top:0; right:0; bottom:0; }
1105 | .fixed-bottomright { bottom:0; right:0; }
1106 | .fixed-bottomleft { bottom:0; left:0; }
1107 | .fixed-topright { top:0; right:0; }
1108 | .fixed-topleft { top:0; left:0; }
1109 |
1110 | /* Container Animations/Transitions
1111 | -------------------------------------------------- */
1112 | .animate {
1113 | -webkit-transition:all .125s;
1114 | -moz-transition:all .125s;
1115 | -ms-transition:all .125s;
1116 | transition:all .125s;
1117 | }
1118 |
1119 | .offcanvas-top {
1120 | -webkit-transform:translateY(-100%);
1121 | -moz-transform:translateY(-100%);
1122 | -ms-transform:translateY(-100%);
1123 | transform:translateY(-100%);
1124 | }
1125 | .offcanvas-right {
1126 | -webkit-transform:translateX(100%);
1127 | -moz-transform:translateX(100%);
1128 | -ms-transform:translateX(100%);
1129 | transform:translateX(100%);
1130 | }
1131 | .offcanvas-bottom {
1132 | -webkit-transform:translateY(100%);
1133 | -moz-transform:translateY(100%);
1134 | -ms-transform:translateY(100%);
1135 | transform:translateY(100%);
1136 | }
1137 | .offcanvas-left {
1138 | -webkit-transform:translateX(-100%);
1139 | -moz-transform:translateX(-100%);
1140 | -ms-transform:translateX(-100%);
1141 | transform:translateX(-100%);
1142 | }
1143 |
1144 | .offcanvas-top.active,
1145 | .offcanvas-bottom.active,
1146 | .offcanvas-top:target,
1147 | .offcanvas-bottom:target {
1148 | -webkit-transform:translateY(0);
1149 | -moz-transform:translateY(0);
1150 | -ms-transform:translateY(0);
1151 | transform:translateY(0);
1152 | }
1153 |
1154 | .offcanvas-left.active,
1155 | .offcanvas-right.active,
1156 | .offcanvas-left:target,
1157 | .offcanvas-right:target {
1158 | -webkit-transform:translateX(0);
1159 | -moz-transform:translateX(0);
1160 | -ms-transform:translateX(0);
1161 | transform:translateX(0);
1162 | }
1163 |
1164 | /* Markup free clearing
1165 | Details: http://www.positioniseverything.net/easyclearing.html
1166 | ------------------------------------------------------- */
1167 | .clearfix { display:block; }
1168 | .clearfix:after {
1169 | content:'.';
1170 | display:block;
1171 | height:0;
1172 | clear:both;
1173 | visibility:hidden;
1174 | }
1175 |
1176 | /* Color fills
1177 | ------------------------------------------------------- */
1178 | .fill-blue { background-color:#3887be; }
1179 | .fill-orange { background-color:#f9886c; }
1180 | .fill-navy { background-color:#28353d; }
1181 | .fill-navy-dark { background-color:#222B30; }
1182 | .fill-purple { background-color:#8a8acb; }
1183 | .fill-light { background-color:#f8f8f8; }
1184 | .fill-red { background-color:#e55e5e; }
1185 | .fill-yellow { background-color:#f1f075; }
1186 | .fill-gray,
1187 | .fill-grey { background:#eee; }
1188 | .fill-darken0 { background-color:rgba(0,0,0,0.1); }
1189 | .fill-darken1 { background-color:rgba(0,0,0,0.25); }
1190 |
1191 | /* Additional Utility Classes
1192 | ------------------------------------------------------- */
1193 | .fr { float:right; }
1194 | .fl { float:left; }
1195 | .dot { border-radius:50%; }
1196 | .light { color:#ccc; color: rgba(0,0,0,.3); }
1197 | .quiet { color:#7f7f7f; color: rgba(0,0,0,.5); }
1198 | .dark .quiet { color: #7f7f7f; color: rgba(255,255,255,.5);}
1199 | .center { text-align:center; }
1200 | .contain { position:relative; }
1201 | .clip { overflow:hidden; }
1202 | .hidden { display:none; }
1203 | .text-left { text-align:left; }
1204 | .text-right { text-align:right; }
1205 | .space > * { margin-right:5px; }
1206 | .space-bottom0 { margin-bottom: 5px;}
1207 | .space-bottom1 { margin-bottom: 10px;}
1208 | .space-bottom2 { margin-bottom: 20px;}
1209 | .space-bottom4 { margin-bottom: 40px;}
1210 | .space-bottom8 { margin-bottom: 80px;}
1211 | .space-left0 { margin-left: 5px;}
1212 | .space-left1 { margin-left: 10px;}
1213 | .space-left2 { margin-left: 20px;}
1214 | .space-left4 { margin-left: 40px;}
1215 | .space-left8 { margin-left: 80px;}
1216 | .space-right0 { margin-right: 5px;}
1217 | .space-right1 { margin-right: 10px;}
1218 | .space-right2 { margin-right: 20px;}
1219 | .space-right4 { margin-right: 40px;}
1220 | .space-right8 { margin-right: 80px;}
1221 | .hide-tablet, .hide-mobile { display:block; }
1222 | .show-tablet, .show-mobile { display:none; }
1223 | .show-mobile { display:none; }
1224 | img.inline, .inline { display:inline-block; }
1225 | .sprite.block,.block { display:block; }
1226 | .round { border-radius:3px; }
1227 | .round-top { border-radius:3px 3px 0 0; }
1228 | .round-right { border-radius:0 3px 3px 0; }
1229 | .round-bottom { border-radius:0 0 3px 3px; }
1230 | .round-left { border-radius:3px 0 0 3px; }
1231 | .round-topleft { border-top-left-radius: 3px;}
1232 | .round-bottomleft { border-bottom-left-radius: 3px;}
1233 | .round-topright { border-top-right-radius: 3px;}
1234 | .round-bottomright { border-bottom-right-radius: 3px;}
1235 |
1236 | .truncate {
1237 | text-overflow:ellipsis;
1238 | white-space:nowrap;
1239 | overflow:hidden;
1240 | }
1241 |
1242 | /* Mobile Layout
1243 | ------------------------------------------------------- */
1244 | @media only screen and (max-width:640px) {
1245 | a:link { -webkit-tap-highlight-color:rgba(0,0,0,0); }
1246 | label .inline a { font-weight:normal; }
1247 | [type=submit] { width:100%; }
1248 |
1249 | .row1 { height:auto; min-height: 40px;}
1250 | .row2 { height:auto; min-height: 80px;}
1251 | .row3 { height:auto; min-height: 120px;}
1252 | .row4 { height:auto; min-height: 160px;}
1253 | .row5 { height:auto; min-height: 200px;}
1254 | .row6 { height:auto; min-height: 240px;}
1255 | .row7 { height:auto; min-height: 280px;}
1256 | .row8 { height:auto; min-height: 320px;}
1257 | .row9 { height:auto; min-height: 360px;}
1258 | .row10 { height:auto; min-height: 400px;}
1259 | .row11 { height:auto; min-height: 440px;}
1260 | .row12 { height:auto; min-height: 480px;}
1261 | .row13 { height:auto; min-height: 520px;}
1262 | .row14 { height:auto; min-height: 560px;}
1263 | .row15 { height:auto; min-height: 600px;}
1264 | .row16 { height:auto; min-height: 640px;}
1265 |
1266 | .col1,
1267 | .col2,
1268 | .col3,
1269 | .col4,
1270 | .col5,
1271 | .col6,
1272 | .col7,
1273 | .col8,
1274 | .col9,
1275 | .col10,
1276 | .col11,
1277 | .col12 { width:100%; max-width:100%; }
1278 | .margin0,
1279 | .margin1,
1280 | .margin2,
1281 | .margin3,
1282 | .margin4,
1283 | .margin5,
1284 | .margin6,
1285 | .margin7,
1286 | .margin8,
1287 | .margin9,
1288 | .margin10,
1289 | .margin11,
1290 | .margin12 { margin-left:0; }
1291 | .title { margin-bottom:10px; }
1292 | .space-bottom2, .space-bottom { margin-bottom:10px; }
1293 | .space-bottom4 { margin-bottom:20px; }
1294 | .hide-mobile { display:none!important; }
1295 | .show-mobile { display:block!important; }
1296 |
1297 | .pill:not(.mobile-cols) > * {
1298 | width:100%;
1299 | border-left-width:0;
1300 | border-bottom-width:1px;
1301 | }
1302 | .pill:not(.mobile-cols) > *:first-child,
1303 | .pill:not(.mobile-cols) > *:first-of-type {
1304 | border-radius:3px 3px 0 0;
1305 | }
1306 | .pill:not(.mobile-cols) > *:last-child,
1307 | .pill:not(.mobile-cols) > *:last-of-type {
1308 | border-bottom-width:0;
1309 | border-radius:0 0 3px 3px;
1310 | }
1311 | .tabs:not(.mobile-cols) > a { border-right-width:0; border-bottom-width:1px; }
1312 | .tabs:not(.mobile-cols) > a:last-child { border-bottom:none; }
1313 |
1314 | .mobile-cols > .col0 { float:left; width:04.1666%; }
1315 | .mobile-cols > .col1 { float:left; width:08.3333%; }
1316 | .mobile-cols > .col2 { float:left; width:16.6666%; }
1317 | .mobile-cols > .col3 { float:left; width:25.0000%; }
1318 | .mobile-cols > .col4 { float:left; width:33.3333%; }
1319 | .mobile-cols > .col5 { float:left; width:41.6666%; }
1320 | .mobile-cols > .col6 { float:left; width:50.0000%; }
1321 | .mobile-cols > .col7 { float:left; width:58.3333%; }
1322 | .mobile-cols > .col8 { float:left; width:66.6666%; }
1323 | .mobile-cols > .col9 { float:left; width:75.0000%; }
1324 | .mobile-cols > .col10 { float:left; width:83.3333%; }
1325 | .mobile-cols > .col11 { float:left; width:91.6666%; }
1326 |
1327 | .mobile-cols > .margin0 { margin-left:04.1666%; }
1328 | .mobile-cols > .margin1 { margin-left:08.3333%; }
1329 | .mobile-cols > .margin2 { margin-left:16.6666%; }
1330 | .mobile-cols > .margin3 { margin-left:25.0000%; }
1331 | .mobile-cols > .margin4 { margin-left:33.3333%; }
1332 | .mobile-cols > .margin5 { margin-left:41.6666%; }
1333 | .mobile-cols > .margin6 { margin-left:50.0000%; }
1334 | .mobile-cols > .margin7 { margin-left:58.3333%; }
1335 | .mobile-cols > .margin8 { margin-left:66.6666%; }
1336 | .mobile-cols > .margin9 { margin-left:75.0000%; }
1337 | .mobile-cols > .margin10 { margin-left:83.3333%; }
1338 | .mobile-cols > .margin11 { margin-left:91.6666%; }
1339 | .mobile-cols > .margin12 { margin-left:100.0000%; }
1340 | }
1341 |
1342 | /* Loading overlay
1343 | ------------------------------------------------------- */
1344 | .loading:after,
1345 | .loading:before {
1346 | content:'';
1347 | display:block;
1348 | position:absolute;
1349 | z-index:10;
1350 | }
1351 | .loading:before {
1352 | background:transparent;
1353 | left:0;
1354 | top:0;
1355 | width:100%;
1356 | height:100%;
1357 | }
1358 | .loading:after {
1359 | background:rgba(0,0,0,.2) url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhLS0gQ3JlYXRlZCB3aXRoIElua3NjYXBlIChodHRwOi8vd3d3Lmlua3NjYXBlLm9yZy8pIC0tPgoKPHN2ZwogICB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iCiAgIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiCiAgIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyIKICAgeG1sbnM6c3ZnPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgogICB4bWxuczpzb2RpcG9kaT0iaHR0cDovL3NvZGlwb2RpLnNvdXJjZWZvcmdlLm5ldC9EVEQvc29kaXBvZGktMC5kdGQiCiAgIHhtbG5zOmlua3NjYXBlPSJodHRwOi8vd3d3Lmlua3NjYXBlLm9yZy9uYW1lc3BhY2VzL2lua3NjYXBlIgogICBpZD0ic3ZnMzEyMiIKICAgdmVyc2lvbj0iMS4xIgogICBpbmtzY2FwZTp2ZXJzaW9uPSIwLjQ4LjUgcjEwMDQwIgogICB3aWR0aD0iMjQiCiAgIGhlaWdodD0iMjQiCiAgIHNvZGlwb2RpOmRvY25hbWU9ImxvYWRzb3VyY2UyLnN2ZyI+CiAgPG1ldGFkYXRhCiAgICAgaWQ9Im1ldGFkYXRhMzEyOCI+CiAgICA8cmRmOlJERj4KICAgICAgPGNjOldvcmsKICAgICAgICAgcmRmOmFib3V0PSIiPgogICAgICAgIDxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0PgogICAgICAgIDxkYzp0eXBlCiAgICAgICAgICAgcmRmOnJlc291cmNlPSJodHRwOi8vcHVybC5vcmcvZGMvZGNtaXR5cGUvU3RpbGxJbWFnZSIgLz4KICAgICAgICA8ZGM6dGl0bGUgLz4KICAgICAgPC9jYzpXb3JrPgogICAgPC9yZGY6UkRGPgogIDwvbWV0YWRhdGE+CiAgPGRlZnMKICAgICBpZD0iZGVmczMxMjYiIC8+CiAgPHNvZGlwb2RpOm5hbWVkdmlldwogICAgIHBhZ2Vjb2xvcj0iI2ZmZmZmZiIKICAgICBib3JkZXJjb2xvcj0iIzY2NjY2NiIKICAgICBib3JkZXJvcGFjaXR5PSIxIgogICAgIG9iamVjdHRvbGVyYW5jZT0iMTAiCiAgICAgZ3JpZHRvbGVyYW5jZT0iMTAiCiAgICAgZ3VpZGV0b2xlcmFuY2U9IjEwIgogICAgIGlua3NjYXBlOnBhZ2VvcGFjaXR5PSIwIgogICAgIGlua3NjYXBlOnBhZ2VzaGFkb3c9IjIiCiAgICAgaW5rc2NhcGU6d2luZG93LXdpZHRoPSIxMTgyIgogICAgIGlua3NjYXBlOndpbmRvdy1oZWlnaHQ9IjcwOCIKICAgICBpZD0ibmFtZWR2aWV3MzEyNCIKICAgICBzaG93Z3JpZD0idHJ1ZSIKICAgICBpbmtzY2FwZTpzbmFwLWJib3g9InRydWUiCiAgICAgaW5rc2NhcGU6b2JqZWN0LW5vZGVzPSJ0cnVlIgogICAgIGlua3NjYXBlOnpvb209IjE2IgogICAgIGlua3NjYXBlOmN4PSI4Ljk3Nzk0NzciCiAgICAgaW5rc2NhcGU6Y3k9IjEwLjczMjQ3NiIKICAgICBpbmtzY2FwZTp3aW5kb3cteD0iNDgyIgogICAgIGlua3NjYXBlOndpbmRvdy15PSIxMjciCiAgICAgaW5rc2NhcGU6d2luZG93LW1heGltaXplZD0iMCIKICAgICBpbmtzY2FwZTpjdXJyZW50LWxheWVyPSJzdmczMTIyIgogICAgIHNob3dndWlkZXM9ImZhbHNlIgogICAgIGlua3NjYXBlOmd1aWRlLWJib3g9InRydWUiCiAgICAgaW5rc2NhcGU6b2JqZWN0LXBhdGhzPSJ0cnVlIgogICAgIGZpdC1tYXJnaW4tdG9wPSIwIgogICAgIGZpdC1tYXJnaW4tbGVmdD0iMCIKICAgICBmaXQtbWFyZ2luLXJpZ2h0PSIwIgogICAgIGZpdC1tYXJnaW4tYm90dG9tPSIwIj4KICAgIDxpbmtzY2FwZTpncmlkCiAgICAgICB0eXBlPSJ4eWdyaWQiCiAgICAgICBpZD0iZ3JpZDMxMzIiCiAgICAgICBlbXBzcGFjaW5nPSI1IgogICAgICAgdmlzaWJsZT0idHJ1ZSIKICAgICAgIGVuYWJsZWQ9InRydWUiCiAgICAgICBzbmFwdmlzaWJsZWdyaWRsaW5lc29ubHk9InRydWUiCiAgICAgICBvcmlnaW54PSItMTQ4cHgiCiAgICAgICBvcmlnaW55PSItMzU4cHgiIC8+CiAgICA8c29kaXBvZGk6Z3VpZGUKICAgICAgIG9yaWVudGF0aW9uPSItMC43MDcxMDY3OCwwLjcwNzEwNjc4IgogICAgICAgcG9zaXRpb249IjEyLDEyIgogICAgICAgaWQ9Imd1aWRlNDEwNSIgLz4KICA8L3NvZGlwb2RpOm5hbWVkdmlldz4KICA8cGF0aAogICAgIHN0eWxlPSJjb2xvcjojMDAwMDAwO2ZpbGw6I2ZmZmZmZjtmaWxsLW9wYWNpdHk6MTtmaWxsLXJ1bGU6bm9uemVybztzdHJva2U6bm9uZTtzdHJva2Utd2lkdGg6MTI7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICBkPSJNIDEyIDAgTCAxMiA1IEMgMTUuODY1OTkzIDUgMTkgOC4xMzQwMDY3IDE5IDEyIEwgMjQgMTIgQyAyNCA1LjM3MjU4MyAxOC42Mjc0MTcgMCAxMiAwIHogIgogICAgIGlkPSJwYXRoMzk1NiIgLz4KICA8cGF0aAogICAgIGlua3NjYXBlOmNvbm5lY3Rvci1jdXJ2YXR1cmU9IjAiCiAgICAgc3R5bGU9Im9wYWNpdHk6MC40O2NvbG9yOiMwMDAwMDA7ZmlsbDojZmZmZmZmO2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTpub25lO3N0cm9rZS13aWR0aDoxMjttYXJrZXI6bm9uZTt2aXNpYmlsaXR5OnZpc2libGU7ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlIgogICAgIGQ9Ik0gMTIsMCBDIDUuMzcyNTgzLDAgMCw1LjM3MjU4MyAwLDEyIGMgMCwzLjE4MjU5OCAxLjI0OTU2Myw2LjI0OTU2MyAzLjUsOC41IDIuMjUwNDM3LDIuMjUwNDM3IDUuMzE3NDAyLDMuNSA4LjUsMy41IDMuMTgyNTk4LDAgNi4yNDk1NjMsLTEuMjQ5NTYzIDguNSwtMy41IEMgMjIuNzUwNDM3LDE4LjI0OTU2MyAyNCwxNS4xODI1OTggMjQsMTIgbCAtNSwwIGMgMCwzLjg2NTk5MyAtMy4xMzQwMDcsNyAtNyw3IEMgOC4xMzQwMDY4LDE5IDUsMTUuODY1OTkzIDUsMTIgNSw4LjEzNDAwNjcgOC4xMzQwMDY4LDUgMTIsNSB6IgogICAgIGlkPSJwYXRoMzE3NCIKICAgICBzb2RpcG9kaTpub2RldHlwZXM9ImNjY2NjY2NjY2NjIiAvPgo8L3N2Zz4K) 50% 50% no-repeat;
1360 | left:50%;
1361 | top:50%;
1362 | margin:-20px 0 0 -20px;
1363 | width:40px;
1364 | height:40px;
1365 | border-radius:50%;
1366 | -webkit-animation: rotate 1s linear infinite;
1367 | -moz-animation: rotate 1s linear infinite;
1368 | -ms-animation: rotate 1s linear infinite;
1369 | animation: rotate 1s linear infinite;
1370 | }
1371 |
1372 | @-webkit-keyframes rotate { from { -webkit-transform: rotate(0); } to { -webkit-transform: rotate(360deg); } }
1373 | @-moz-keyframes rotate { from { -moz-transform: rotate(0); } to { -moz-transform: rotate(360deg); } }
1374 | @-ms-keyframes rotate { from { -ms-transform: rotate(0); } to { -ms-transform: rotate(360deg); } }
1375 | @keyframes rotate { from { transform: rotate(0); } to { transform: rotate(360deg); } }
1376 |
--------------------------------------------------------------------------------
/css/icon.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cutting-room-floor/team-directory/d5f2585bf83a0976abb724a0e28898a67f50277f/css/icon.eot
--------------------------------------------------------------------------------
/css/icon.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cutting-room-floor/team-directory/d5f2585bf83a0976abb724a0e28898a67f50277f/css/icon.woff
--------------------------------------------------------------------------------
/data/form.json:
--------------------------------------------------------------------------------
1 | {
2 | "Basic information": [{
3 | "key": "admin",
4 | "type": "hidden"
5 | }, {
6 | "key": "fname",
7 | "label": "First name",
8 | "required": true
9 | }, {
10 | "key": "lname",
11 | "label": "Last name",
12 | "required": true
13 | }, {
14 | "key": "email",
15 | "label": "Email",
16 | "required": true
17 | }, {
18 | "key": "sex",
19 | "label": "Gender",
20 | "type": "radio",
21 | "fields": [{
22 | "key": "xy",
23 | "label": "Male"
24 | }, {
25 | "key": "xx",
26 | "label": "Female"
27 | }]
28 | }, {
29 | "key": "birthday",
30 | "label": "Birthday",
31 | "type": "date"
32 | }, {
33 | "key": "call",
34 | "label": "Mobile number",
35 | "type": "number"
36 | }, {
37 | "key": "admin",
38 | "label": "Grant admin access?",
39 | "admin": true,
40 | "type": "radio",
41 | "fields": [{
42 | "key": true,
43 | "label": "True"
44 | }, {
45 | "key": false,
46 | "label": "False"
47 | }]
48 | }, {
49 | "key": "office",
50 | "label": "Office",
51 | "type": "radio",
52 | "fields": [{
53 | "key": "dc",
54 | "label": "DC"
55 | }, {
56 | "key": "sf",
57 | "label": "SF"
58 | }, {
59 | "key": "ayacucho",
60 | "label": "Peru"
61 | }, {
62 | "key": "bengaluru",
63 | "label": "India"
64 | }]
65 | }, {
66 | "key": "homeaddress",
67 | "label": "Home Address",
68 | "type": "textarea"
69 | }, {
70 | "key": "teams",
71 | "label": "Teams (check all that apply)",
72 | "required": true,
73 | "type": "checkbox",
74 | "fields": [{
75 | "key": "business",
76 | "label": "Business"
77 | }, {
78 | "key": "design",
79 | "label": "Design"
80 | }, {
81 | "key": "engineering",
82 | "label": "Engineering"
83 | }, {
84 | "key": "operations",
85 | "label": "Operations"
86 | }, {
87 | "key": "support",
88 | "label": "Support"
89 | }]
90 | }],
91 | "Social links": [{
92 | "key": "twitter",
93 | "label": "Twitter username"
94 | }, {
95 | "key": "github",
96 | "label": "Github username",
97 | "required": true
98 | }, {
99 | "key": "foursquare",
100 | "label": "Foursquare username"
101 | }, {
102 | "key": "other-links",
103 | "type": "add",
104 | "label": "Other links"
105 | }]
106 | }
107 |
--------------------------------------------------------------------------------
/dist/team-directory.css:
--------------------------------------------------------------------------------
1 | body { overflow-y:scroll; }
2 | form > fieldset { margin:0; }
3 | .space-right0 { margin-right:5px; }
4 | form fieldset:first-child,
5 | .no-last-keyline:last-child { border:none; }
6 | .question { cursor:help; }
7 | .square4 {
8 | width:40px;
9 | height:40px;
10 | }
11 | [data-tooltip] {
12 | cursor:pointer;
13 | position:relative;
14 | }
15 | [data-tooltip]:after {
16 | content:attr(data-tooltip);
17 | position:absolute;
18 | background:#404040;
19 | color:#fff;
20 | border-radius:3px;
21 | bottom:20px;
22 | display:none;
23 | padding:0 10px;
24 | width:auto;
25 | min-width:75px;
26 | left:0;
27 | margin-left:-10px;
28 | font-size:12px;
29 | height:30px;
30 | line-height:30px;
31 | text-align:center;
32 | }
33 | [data-tooltip]:before {
34 | content:'';
35 | display:block;
36 | border-left:5px solid transparent;
37 | border-right:5px solid transparent;
38 | border-top:5px solid #404040;
39 | display:none;
40 | position:absolute;
41 | bottom:15px;
42 | left:5px;
43 | }
44 | [data-tooltip]:hover:before,
45 | [data-tooltip]:hover:after {
46 | display:block;
47 | }
48 | .min-containment {
49 | min-height:600px;
50 | }
51 | .listing > *:last-child { border-bottom:none; }
52 | .colfifths { width:20%; float:left; }
53 | .text-alert { color:#f9886c; }
54 |
55 | /* When rendering groups react requires elements
56 | * to be wrapped in containers which breaks default
57 | * pill class rules. This brings them back */
58 | .pill .react > * {
59 | border-radius:0 0 0 0;
60 | border:0 solid white;
61 | border-left-width:1px;
62 | }
63 | .pill .react:first-of-type > * {
64 | border-radius:3px 0 0 3px;
65 | border-left-width:0;
66 | }
67 | .pill .react:last-of-type > * {
68 | border-radius:0 3px 3px 0;
69 | }
70 | .pill .react:only-child > * {
71 | border-radius:3px;
72 | }
73 |
74 | .pill .set12:first-of-type > * {
75 | border-radius:3px 3px 0 0;
76 | }
77 | .pill .set12:last-of-type > * {
78 | border-radius:0 0 3px 3px;
79 | }
80 | .pill .set12 > * {
81 | text-align:left;
82 | width:100%;
83 | border-left-width:0;
84 | border-bottom-width:1px;
85 | }
86 |
87 | .loading:before { background-color:rgba(0,0,0,0.25); }
88 | .loading:before,
89 | .loading:after { position:fixed; }
90 |
91 | fieldset:target > input {
92 | border-color:#f9886c;
93 | }
94 |
95 | nav.primary a {
96 | display:inline-block;
97 | margin-right:10px;
98 | box-shadow:inset 0 -3px 0 transparent;
99 | }
100 | nav.primary a:hover {
101 | box-shadow:inset 0 -3px 0 rgba(0,0,0,0.10);
102 | }
103 | nav.primary a.active {
104 | box-shadow:inset 0 -3px 0 #142736;
105 | }
106 |
107 | .rounded-toggle label {
108 | cursor:pointer;
109 | vertical-align:top;
110 | display:inline-block;
111 | border-radius:16px;
112 | padding:3px 10px;
113 | font-size:12px;
114 | line-height:20px;
115 | }
116 | .rounded-toggle > span {
117 | padding:0;
118 | }
119 |
120 | .error-console { word-wrap:break-word; }
121 |
122 | /* react autosuggest */
123 | .react-autosuggest__container {
124 | position:relative;
125 | }
126 | .react-autosuggest__input {
127 | width:100%;
128 | height:40px;
129 | padding:10px;
130 | border:1px solid rgba(0,0,0,0.25);
131 | border-radius:3px;
132 | }
133 | .react-autosuggest__input:focus {
134 | outline:none;
135 | }
136 | .react-autosuggest__container--open .react-autosuggest__input {
137 | border-bottom-left-radius:0;
138 | border-bottom-right-radius:0;
139 | }
140 | .react-autosuggest__suggestions-container {
141 | position:absolute;
142 | top:41px;
143 | width:100%;
144 | margin:0;
145 | padding:0;
146 | list-style-type:none;
147 | border:1px solid rgba(0,0,0,0.25);
148 | background-color:#fff;
149 | border-bottom-left-radius:3px;
150 | border-bottom-right-radius:3px;
151 | z-index:2;
152 | }
153 | .react-autosuggest__suggestion {
154 | cursor:pointer;
155 | padding:10px;
156 | }
157 | .react-autosuggest__suggestion--focused {
158 | background-color:rgba(0,0,0,0.1);
159 | }
160 |
161 |
162 | @media only screen and (max-width:640px) {
163 | .link a { padding:2.5px 0; }
164 | .pill:not(.mobile-cols) .react:first-of-type > * {
165 | border-radius:3px 3px 0 0;
166 | }
167 | .pill:not(.mobile-cols) .react:last-of-type > * {
168 | border-radius:0 0 3px 3px;
169 | }
170 | .pill:not(.mobile-cols) .react > * {
171 | width:100%;
172 | border-left-width:0;
173 | border-bottom-width:1px;
174 | }
175 | }
176 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | import TeamDirectory from './src';
2 |
3 | function Directory(id, options) {
4 | return new TeamDirectory(id, options);
5 | }
6 |
7 | if (window && typeof module !== 'undefined' && module.exports) {
8 | window.TeamDirectory = module.exports = Directory;
9 | } else if (typeof module !== 'undefined' && module.exports) {
10 | module.exports = Directory;
11 | } else {
12 | window.TeamDirectory = Directory;
13 | }
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "team-directory",
3 | "version": "1.6.5",
4 | "description": "A rolodex for teams",
5 | "main": "index.js",
6 | "browserify": {
7 | "transform": [
8 | "envify",
9 | "babelify"
10 | ]
11 | },
12 | "scripts": {
13 | "start": "budo index.js --serve=dist/team-directory.js --live -d --pushstate",
14 | "build": "browserify index.js | uglifyjs -c -m > dist/team-directory.js",
15 | "test": "npm run lint && browserify test/test.js | smokestack -b firefox | tap-status",
16 | "test-local": "./.env.sh ; npm run lint && browserify test/test.js | smokestack -b firefox | tap-status",
17 | "lint": "eslint --no-eslintrc -c .eslintrc index.js src test"
18 | },
19 | "repository": {
20 | "type": "git",
21 | "url": "git+ssh://git@github.com/mapbox/team-directory.git"
22 | },
23 | "keywords": [
24 | "directory",
25 | "rolodex",
26 | "contacts"
27 | ],
28 | "author": "mapbox",
29 | "license": "BSD-3-Clause",
30 | "bugs": {
31 | "url": "https://github.com/mapbox/team-directory/issues"
32 | },
33 | "homepage": "https://github.com/mapbox/team-directory#readme",
34 | "devDependencies": {
35 | "babel-core": "^5.8.25",
36 | "babel-eslint": "^4.1.5",
37 | "babel-plugin-object-assign": "^1.2.1",
38 | "babelify": "^6.3.0",
39 | "browserify": "^11.2.0",
40 | "budo": "^7.0.2",
41 | "envify": "^3.4.0",
42 | "eslint": "^1.7.2",
43 | "eslint-plugin-react": "^3.10.0",
44 | "smokestack": "^3.4.1",
45 | "tap-status": "^1.0.1",
46 | "tape": "^4.2.2",
47 | "uglify-js": "^2.6.1"
48 | },
49 | "dependencies": {
50 | "filesaver.js": "^0.2.0",
51 | "fuzzy": "^0.1.1",
52 | "history": "1.12.5",
53 | "js-base64": "^2.1.9",
54 | "json-csv": "^1.3.0",
55 | "md5-jkmyers": "0.0.1",
56 | "octokat": "^0.4.13",
57 | "react": "^0.14.0",
58 | "react-autosuggest": "^3.6.0",
59 | "react-document-title": "^2.0.1",
60 | "react-dom": "^0.14.0",
61 | "react-modal": "^0.6.1",
62 | "react-redux": "^3.0.1",
63 | "react-router": "1.0.1",
64 | "redux": "^3.0.2",
65 | "redux-simple-router": "0.0.10",
66 | "redux-thunk": "^1.0.0",
67 | "vcf": "^1.1.2"
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/actions/index.js:
--------------------------------------------------------------------------------
1 | import * as types from '../constants/action_types';
2 | import Octokat from 'octokat';
3 | import { Base64 } from 'js-base64';
4 | import fuzzy from 'fuzzy';
5 | let client, repo, config = {};
6 |
7 | function setActor(actor) {
8 | return {
9 | type: types.ACTOR,
10 | actor
11 | };
12 | }
13 |
14 | function setTeam(team) {
15 | return {
16 | type: types.TEAM,
17 | team
18 | };
19 | }
20 |
21 | function setFilter(filterList) {
22 | return {
23 | type: types.FILTER_LIST,
24 | filterList
25 | };
26 | }
27 |
28 | function setForm(form) {
29 | return {
30 | type: types.FORM,
31 | form
32 | };
33 | }
34 |
35 | export function setMessage(message) {
36 | return {
37 | type: types.MESSAGE,
38 | message
39 | };
40 | }
41 |
42 | export function setError(error) {
43 | return (dispatch) => {
44 | if (typeof error === 'object') error = JSON.parse(error.message).message;
45 | dispatch(isLoading(false));
46 | dispatch({
47 | type: types.ERROR,
48 | error
49 | });
50 | }
51 | }
52 |
53 | export function setValidators(validators) {
54 | return {
55 | type: types.VALIDATORS,
56 | validators
57 | };
58 | }
59 |
60 | export function setStatsTemplate(statsTemplate) {
61 | return {
62 | type: types.STATS_TEMPLATE,
63 | statsTemplate
64 | };
65 | }
66 |
67 | export function setListingTemplate(listingTemplate) {
68 | return {
69 | type: types.LISTING_TEMPLATE,
70 | listingTemplate
71 | };
72 | }
73 |
74 | export function setNormalizers(normalizers) {
75 | return {
76 | type: types.NORMALIZERS,
77 | normalizers
78 | };
79 | }
80 |
81 | export function setOptions(options) {
82 | client = new Octokat({ token: options.GitHubToken });
83 | repo = client.repos(options.account, options.repo);
84 |
85 | return {
86 | type: types.OPTIONS,
87 | options
88 | }
89 | }
90 |
91 | export function addUser(obj, cb) {
92 | return (dispatch, getState) => {
93 | const { options, team } = getState().directory;
94 | const config = {};
95 | if (options.branch) config.ref = options.branch;
96 |
97 | dispatch(isLoading(true));
98 |
99 | repo.contents(options.team).fetch(config).then((res) => {
100 |
101 | let dataFromGitHub = res.content ? JSON.parse(Base64.decode(res.content)) : res;
102 | dataFromGitHub.push(obj); // New record
103 |
104 | const payload = JSON.stringify(dataFromGitHub, null, 2) + '\n';
105 | const putData = {
106 | message: 'Created ' + obj.github,
107 | content: Base64.encode(payload),
108 | sha: res.sha
109 | };
110 |
111 | if (options.branch) putData.branch = options.branch;
112 |
113 | repo.contents(options.team).add(putData)
114 | .then(() => {
115 | dispatch(setFilter(dataFromGitHub));
116 | dispatch(setTeam(dataFromGitHub));
117 | dispatch(eventEmit('user.created', { user: obj }));
118 | dispatch(isLoading(false));
119 | cb(null);
120 | }).catch((err) => { cb(err); });
121 | }).catch((err) => { cb(err); });
122 | }
123 | }
124 |
125 | export function updateUser(obj, cb) {
126 | return (dispatch, getState) => {
127 | const { options, team } = getState().directory;
128 | const config = {};
129 | if (options.branch) config.ref = options.branch;
130 | dispatch(isLoading(true));
131 |
132 | repo.contents(options.team).fetch(config).then((res) => {
133 | let dataFromGitHub = res.content ? JSON.parse(Base64.decode(res.content)) : res;
134 | dataFromGitHub = dataFromGitHub.map((d) => {
135 | if (obj.github.toLowerCase() === d.github.toLowerCase()) d = obj;
136 | return d;
137 | });
138 |
139 | const payload = JSON.stringify(dataFromGitHub, null, 2) + '\n';
140 | const putData = {
141 | message: 'Updated ' + obj.github,
142 | content: Base64.encode(payload),
143 | sha: res.sha
144 | };
145 |
146 | if (options.branch) putData.branch = options.branch;
147 |
148 | repo.contents(options.team).add(putData)
149 | .then(() => {
150 | dispatch(setFilter(dataFromGitHub));
151 | dispatch(setTeam(dataFromGitHub));
152 | dispatch(eventEmit('user.updated', { user: obj }));
153 | dispatch(isLoading(false));
154 | cb(null);
155 | }).catch((err) => { cb(err); });
156 | }).catch((err) => { cb(err); });
157 | }
158 | }
159 |
160 | export function removeUser(username, cb) {
161 | return (dispatch, getState) => {
162 | const { options, team } = getState().directory;
163 | const config = {};
164 | if (options.branch) config.ref = options.branch;
165 | dispatch(isLoading(true));
166 |
167 | repo.contents(options.team).fetch(config).then((res) => {
168 | let user;
169 | let dataFromGitHub = res.content ? JSON.parse(Base64.decode(res.content)) : res;
170 |
171 | dataFromGitHub = dataFromGitHub.filter((d) => {
172 | if (username.toLowerCase() === d.github.toLowerCase()) {
173 | user = d;
174 | return false;
175 | } else {
176 | return true;
177 | }
178 | });
179 |
180 | const payload = JSON.stringify(dataFromGitHub, null, 2) + '\n';
181 | const putData = {
182 | message: 'Removed ' + username,
183 | content: Base64.encode(payload),
184 | sha: res.sha
185 | };
186 |
187 | if (options.branch) putData.branch = options.branch;
188 |
189 | repo.contents(options.team).add(putData)
190 | .then(() => {
191 | dispatch(setFilter(dataFromGitHub));
192 | dispatch(setTeam(dataFromGitHub));
193 | dispatch(eventEmit('user.removed', { user: user }));
194 | dispatch(isLoading(false));
195 | cb(null);
196 | }).catch((err) => { cb(err); });
197 | }).catch((err) => { cb(err); });
198 | }
199 | }
200 |
201 | export function loadForm() {
202 | return (dispatch, getState) => {
203 | const { options } = getState().directory;
204 | const config = {};
205 | if (options.branch) config.ref = options.branch;
206 |
207 | repo.contents(options.form).read(config)
208 | .then((res) => {
209 | res = JSON.parse(res);
210 | let data = [];
211 | for (const prop in res) {
212 | data.push({
213 | section: prop,
214 | data: res[prop]
215 | });
216 | }
217 | dispatch(setForm(data));
218 | })
219 | .catch((err) => {
220 | dispatch(setError(err));
221 | dispatch(setForm([]));
222 | });
223 | };
224 | }
225 |
226 | export function loadUser(u) {
227 | return (dispatch, getState) => {
228 | const { team } = getState().directory;
229 | const user = team.filter((d) => {
230 | return u.toLowerCase() === d.github.toLowerCase();
231 | })[0];
232 |
233 | dispatch(eventEmit('user.editing', { user: user }));
234 | dispatch({
235 | type: types.USER,
236 | user
237 | });
238 | };
239 | }
240 |
241 | export function loadTeam() {
242 | return (dispatch, getState) => {
243 | dispatch(isLoading(true));
244 | const { options } = getState().directory;
245 | const config = {};
246 | if (options.branch) config.ref = options.branch;
247 |
248 | repo.contents(options.team).read(config)
249 | .then((data) => {
250 | data = JSON.parse(data);
251 |
252 | client.user.fetch(config)
253 | .then((user) => {
254 |
255 | data.forEach((d) => {
256 | if (d.github.toLowerCase() === user.login.toLowerCase() && d.admin) {
257 | user.admin = true;
258 | }
259 | });
260 |
261 | dispatch(setActor(user));
262 | dispatch(setFilter(data));
263 | dispatch(setTeam(data));
264 | dispatch(eventEmit('load', { team: data, user: user }));
265 | dispatch(isLoading(false));
266 | });
267 | })
268 | .catch((err) => {
269 | dispatch(setError(err));
270 | dispatch(setTeam([]));
271 | dispatch(setFilter([]));
272 | });
273 | };
274 | }
275 |
276 | export function teamSort(sortIndex) {
277 | return (dispatch, getState) => {
278 | const { sorts, filterList } = getState().directory;
279 | dispatch(setFilter(sorts[sortIndex].sort(filterList)));
280 | }
281 | }
282 |
283 | export function teamFilter(query) {
284 | return (dispatch, getState) => {
285 | const { options, team } = getState().directory;
286 | query = decodeURIComponent(query.toLowerCase());
287 |
288 | if (query.length > 2) {
289 | const results = fuzzy.filter(query, team, {
290 | extract: function(d) {
291 | const lookup = [];
292 |
293 | // Combine keys found in`options.filterKeys`
294 | options.filterKeys.forEach(function(key) {
295 | if (typeof key === 'object') {
296 | // TODO Keeping this around for now to avoid a breaking change.
297 | key.forEach(function(k) {
298 | lookup.push(d[k]);
299 | });
300 | }
301 | lookup.push(d[key]);
302 | });
303 |
304 | return lookup.join(' ');
305 | }
306 | });
307 |
308 | dispatch(setFilter(results.map(function(d) {
309 | return d.original;
310 | })));
311 | } else {
312 | dispatch(setFilter(team));
313 | }
314 | }
315 | }
316 |
317 | function sorts(sorts) {
318 | return {
319 | type: types.SORTS,
320 | sorts
321 | };
322 | }
323 |
324 | function sortKeys(sortKeys) {
325 | return {
326 | type: types.SORT_KEYS,
327 | sortKeys
328 | };
329 | }
330 |
331 | export function setSorts(arr) {
332 | return (dispatch) => {
333 | dispatch(sorts(arr));
334 | dispatch(sortKeys(arr.reduce((memo, sort) => {
335 | memo.push(sort.key);
336 | return memo;
337 | }, [])));
338 | }
339 | }
340 |
341 | export function dismissError() {
342 | return (dispatch) => {
343 | dispatch(setError(''));
344 | };
345 | }
346 |
347 | export function dismissModal() {
348 | return (dispatch) => {
349 | dispatch(setMessage(''));
350 | };
351 | }
352 |
353 | export function eventSubscribe(type, fn) {
354 | return (dispatch, getState) => {
355 | const { events } = getState().directory;
356 | events[type] = events[type] || [];
357 | events[type].push(fn);
358 | return {
359 | type: types.EVENTS,
360 | events
361 | };
362 | }
363 | }
364 |
365 | export function eventEmit(type, data) {
366 | return (dispatch, getState) => {
367 | const { events } = getState().directory;
368 |
369 | if (!events[type]) {
370 | return {
371 | type: types.EVENTS,
372 | events
373 | }
374 | }
375 |
376 | const listeners = events[type].slice();
377 |
378 | for (var i = 0; i < listeners.length; i++) {
379 | listeners[i].call(this, data);
380 | }
381 | }
382 | }
383 |
384 | export function isLoading(loading) {
385 | return {
386 | type: types.LOADING,
387 | loading
388 | };
389 | }
390 |
--------------------------------------------------------------------------------
/src/components/error.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 |
3 | export default class ErrorDialog extends Component {
4 | render() {
5 | const { error, dismissError } = this.props;
6 | const active = (error) ? 'active' : '';
7 | if (active) window.setTimeout(dismissError, 3000);
8 | return (
9 |
10 |
11 |
12 | {error}
13 |
14 |
15 |
16 | );
17 | }
18 | }
19 |
20 | ErrorDialog.propTypes = {
21 | error: PropTypes.string.isRequired,
22 | dismissError: PropTypes.func.isRequired
23 | };
24 |
--------------------------------------------------------------------------------
/src/components/filter.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { Route } from 'react-router';
3 |
4 | export default class Filter extends Component {
5 |
6 | constructor(props, context) {
7 | super(props, context);
8 | }
9 |
10 | componentWillMount() {
11 | const { query, filter, sort, sortKeys } = this.props;
12 | if (query.filter) filter(query.filter);
13 | if (sortKeys.length) {
14 | if (query.sort) {
15 | sort(sortKeys.indexOf(query.sort));
16 | } else {
17 | sort(sortKeys.indexOf(sortKeys[0]));
18 | }
19 | }
20 | }
21 |
22 | sort(e) {
23 | const { directory, sort, updatePath, query } = this.props;
24 | const index = encodeURIComponent(e.target.id);
25 | const value = encodeURIComponent(e.target.value);
26 | const bp = directory.options.basePath;
27 | updatePath(query.filter ? `${bp}?filter=${query.filter}&sort=${value}` : `${bp}?sort=${value}`);
28 | sort(index);
29 | }
30 |
31 | filter(e) {
32 | const { directory, filter, updatePath, query } = this.props;
33 | const value = encodeURIComponent(e.target.value);
34 | const bp = directory.options.basePath;
35 | updatePath(query.sort ? `${bp}?filter=${value}&sort=${query.sort}` : `${bp}?filter=${value}`);
36 | filter(value);
37 | }
38 |
39 | render() {
40 | const { query, sortKeys } = this.props;
41 | const filterValue = (query.filter) ? decodeURIComponent(query.filter) : '';
42 | const activeSort = (query.sort) ? query.sort : sortKeys[0];
43 |
44 | return (
45 |
46 |
47 |
48 | {sortKeys.length &&
49 |
59 |
}
60 |
61 | );
62 | }
63 | }
64 |
65 | Filter.propTypes = {
66 | sortKeys: PropTypes.array.isRequired,
67 | updatePath: PropTypes.func.isRequired,
68 | query: PropTypes.object.isRequired,
69 | directory: PropTypes.object.isRequired,
70 | filter: PropTypes.func.isRequired,
71 | sort: PropTypes.func
72 | };
73 |
--------------------------------------------------------------------------------
/src/components/form.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import ReactDOM from 'react-dom';
3 |
4 | // Form components
5 | import Textarea from './form_types/textarea';
6 | import Native from './form_types/native';
7 | import Radio from './form_types/radio';
8 | import Checkbox from './form_types/checkbox';
9 | import Add from './form_types/add';
10 | import AddSingle from './form_types/add_single';
11 | import Select from './form_types/select';
12 |
13 | export default class Form extends Component {
14 | constructor(props, context) {
15 | super(props, context);
16 | const { user, data } = this.props;
17 | this.state = this.mapUser(user, data);
18 | }
19 |
20 | componentWillReceiveProps(next) {
21 | const { user, data } = next;
22 | if (!this.submission) this.setState(this.mapUser(user, data));
23 | }
24 |
25 | mapUser(user, data) {
26 | // Map any existing user data to form properties.
27 | return data.reduce((memo, section) => {
28 | section.data.forEach((field) => {
29 | memo[field.key] = '';
30 | if (user) {
31 | if (user[field.key] || typeof user[field.key] === 'boolean') {
32 | memo[field.key] = user[field.key];
33 | }
34 | }
35 | });
36 | return memo;
37 | }, {});
38 | }
39 |
40 | exists(value) {
41 | var { team } = this.props;
42 | return team.some((user) => {
43 | return user.github.toLowerCase() === value.toLowerCase();
44 | });
45 | }
46 |
47 | formData() {
48 | const { data } = this.props;
49 | let formData = [];
50 |
51 | for (const p in data) {
52 | formData = formData.concat(data[p].data);
53 | }
54 | return formData;
55 | }
56 |
57 | onDelete(e) {
58 | const { onDelete, user } = this.props;
59 | e.preventDefault();
60 | onDelete();
61 | }
62 |
63 | onSubmit(e) {
64 | e.preventDefault();
65 | this.submission = true;
66 |
67 | const data = this.state;
68 | const { setError, onSubmit, user, validators, normalizers } = this.props;
69 |
70 | // - Check that GitHub username does not exist.
71 | if (this.exists(data.github) && !user) {
72 | return setError(`User ${data.github} already exists.`);
73 | }
74 |
75 | // - Check all the required fields.
76 | const missingRequired = this.formData().filter((d) => {
77 | return d.required;
78 | }).filter((d) => {
79 | let contains;
80 | Object.keys(data).forEach(key => {
81 | // If the key we are looking falls under required
82 | if (key === d.key) {
83 | const value = data[key];
84 | if (typeof value === 'string' && value ||
85 | typeof value === 'boolean' ||
86 | typeof value === 'object' && value.length) contains = true;
87 | }
88 | });
89 |
90 | return !contains;
91 | });
92 |
93 | if (missingRequired.length) {
94 | const requiredList = missingRequired.reduce((memo, req) => {
95 | memo.push('"' + req.label + '"');
96 | return memo;
97 | }, []).join(', ');
98 |
99 | return setError(`Missing required fields ${requiredList}`);
100 | }
101 |
102 | // Validate
103 | validators(data, (err) => {
104 | if (err) return setError(err);
105 |
106 | // Normalize
107 | for (const key in data) {
108 |
109 | // Remove unfilled values
110 | if (!data[key]) {
111 | if (typeof data[key] !== 'boolean') delete data[key];
112 | }
113 |
114 | if (typeof data[key] === 'object') {
115 |
116 | // Object structures (provided by "Add" inputs).
117 | // Ensure a value was filled. Otherwise remove it.
118 | data[key] = data[key].filter((d) => {
119 | let hasValue;
120 | for (let prop in d) {
121 | if (d[prop]) hasValue = true;
122 | }
123 | return hasValue;
124 | });
125 | }
126 | }
127 |
128 | // Client normalization
129 | normalizers(data, (res) => {
130 | onSubmit(res); // Submit!
131 | });
132 | });
133 | }
134 |
135 | setFormValue(k, v) {
136 | var obj = {};
137 | obj[k] = v;
138 | this.setState(obj);
139 | }
140 |
141 | render() {
142 | const { data, actor, user, onDelete } = this.props;
143 | const addFields = function(d, i) {
144 | const placeholder = (d.placeholder) ? d.placeholder : d.label ? d.label : '';
145 | const type = (d.type) ? d.type : 'text';
146 | const hidden = (type === 'hidden') ? 'hidden' : false;
147 | const defaultRenderer = (type === 'text' ||
148 | type === 'date' ||
149 | type === 'hidden' ||
150 | type === 'number');
151 |
152 | if (d.admin && !actor.admin) return;
153 |
154 | return (
155 |
156 | {d.label}
157 | {d.required && * }
158 | {d.admin && admin
161 | }
162 |
163 | {type === 'textarea' && }
170 | {type === 'radio' && }
176 | {type === 'checkbox' && }
182 | {type === 'add' && }
187 | {type === 'add-single' && }
193 | {type === 'select' && }
200 | {defaultRenderer && }
208 |
209 | );
210 | }.bind(this);
211 |
212 | const renderSection = function(section, i) {
213 | return (
214 |
215 | {section.section}
216 |
217 | {section.data.map(addFields)}
218 |
219 |
220 | );
221 | };
222 |
223 | return (
224 |
240 | );
241 | }
242 | }
243 |
244 | Form.propTypes = {
245 | data: PropTypes.array.isRequired,
246 | setError: PropTypes.func.isRequired,
247 | onSubmit: PropTypes.func.isRequired,
248 | team: PropTypes.array.isRequired,
249 | actor: PropTypes.object.isRequired,
250 | onDelete: PropTypes.func,
251 | user: PropTypes.object,
252 | validators: PropTypes.func,
253 | normalizers: PropTypes.func
254 | }
255 |
--------------------------------------------------------------------------------
/src/components/form_types/add.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { colN } from '../../utils';
4 |
5 | export default class Add extends Component {
6 |
7 | constructor() {
8 | super();
9 | this.change = this.change.bind(this);
10 | this.add = this.add.bind(this);
11 | this.remove = this.remove.bind(this);
12 | }
13 |
14 | change(e) {
15 | const { onChange, id, value } = this.props;
16 | const fields = ReactDOM.findDOMNode(this.refs.node).getElementsByTagName('div');
17 | const group = [];
18 |
19 | Array.prototype.forEach.call(fields, (el) => {
20 | const item = el.getElementsByTagName('input');
21 | const pairings = [];
22 |
23 | // Name/Value pairings
24 | Array.prototype.forEach.call(item, (itm) => {
25 | pairings.push(itm.value);
26 | });
27 |
28 | if (pairings[0] || pairings[1]) {
29 | group.push({
30 | name: pairings[0],
31 | value: pairings[1]
32 | });
33 | }
34 | });
35 |
36 | onChange(id, group);
37 | }
38 |
39 | add(e) {
40 | e.preventDefault();
41 | const { onChange, id, value } = this.props;
42 | let group = value ? value : [];
43 | group.push({name: '', value: ''});
44 | onChange(id, group);
45 | }
46 |
47 | remove(e) {
48 | e.preventDefault();
49 | const { onChange, id, value } = this.props;
50 | const index = parseInt(e.target.getAttribute('data-index'), 10);
51 | let group = value ? value : [];
52 | group = group.filter((_, i) => i !== index);
53 | onChange(id, group);
54 | }
55 |
56 | render() {
57 | const { id, value } = this.props;
58 | const addToList = function(field, i) {
59 | return (
60 |
67 |
74 |
82 |
90 |
91 | );
92 | }.bind(this);
93 |
94 | return (
95 |
96 | {value && value.map(addToList)}
97 |
101 | Add
102 |
103 |
104 | );
105 | }
106 | }
107 |
108 | Add.propTypes = {
109 | id: PropTypes.string.isRequired,
110 | value: React.PropTypes.oneOfType([
111 | React.PropTypes.string,
112 | React.PropTypes.array
113 | ]),
114 | onChange: PropTypes.func.isRequired
115 | }
116 |
--------------------------------------------------------------------------------
/src/components/form_types/add_single.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import ReactDOM from 'react-dom';
3 |
4 | export default class AddSingle extends Component {
5 |
6 | constructor() {
7 | super();
8 | this.change = this.change.bind(this);
9 | this.add = this.add.bind(this);
10 | this.remove = this.remove.bind(this);
11 | }
12 |
13 | change(e) {
14 | const { onChange, id, value } = this.props;
15 | const index = parseInt(e.target.getAttribute('data-index'), 10);
16 | let group = value ? value : [];
17 | group = group.map((d, i) => {
18 | if (i === index) d = e.target.value;
19 | return d;
20 | });
21 | onChange(id, group);
22 | }
23 |
24 | add(e) {
25 | e.preventDefault();
26 | const { onChange, id, value } = this.props;
27 | let group = value ? value : [];
28 | group.push('');
29 | onChange(id, group);
30 | }
31 |
32 | remove(e) {
33 | e.preventDefault();
34 | const { onChange, id, value } = this.props;
35 | const index = parseInt(e.target.getAttribute('data-index'), 10);
36 | let group = value ? value : [];
37 | group = group.filter((_, i) => i !== index);
38 | onChange(id, group);
39 | }
40 |
41 | render() {
42 | const { id, value, placeholder } = this.props;
43 | const addToList = function(field, i) {
44 | return (
45 |
52 |
59 |
68 |
69 | );
70 | }.bind(this);
71 |
72 | return (
73 |
74 | {value && value.map(addToList)}
75 |
79 | Add
80 |
81 |
82 | );
83 | }
84 | }
85 |
86 | AddSingle.propTypes = {
87 | id: PropTypes.string.isRequired,
88 | value: React.PropTypes.oneOfType([
89 | React.PropTypes.string,
90 | React.PropTypes.array
91 | ]),
92 | onChange: PropTypes.func.isRequired,
93 | placeholder: PropTypes.string
94 | }
95 |
--------------------------------------------------------------------------------
/src/components/form_types/checkbox.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { colN } from '../../utils';
4 |
5 | export default class Checkbox extends Component {
6 |
7 | constructor() {
8 | super();
9 | this.change = this.change.bind(this);
10 | }
11 |
12 | change(e) {
13 | const { onChange, id, value } = this.props;
14 | const checked = value ? value : [];
15 | const v = e.target.id.replace(id + '-', '');
16 | const index = checked.indexOf(v);
17 |
18 | if (index > -1) {
19 | checked.splice(index, 1);
20 | } else {
21 | checked.push(v);
22 | }
23 |
24 | onChange(id, checked);
25 | }
26 |
27 | render() {
28 | const { id, fields, value } = this.props;
29 |
30 | const renderFields = function(field, i) {
31 | const n = colN(fields.length);
32 | return (
33 |
34 | -1}
39 | onChange={this.change}
40 | />
41 |
44 | {field.label}
45 |
46 |
47 | );
48 | }.bind(this);
49 |
50 | return (
51 |
52 | {fields.map(renderFields)}
53 |
54 | );
55 | }
56 | }
57 |
58 | Checkbox.propTypes = {
59 | id: PropTypes.string.isRequired,
60 | value: React.PropTypes.oneOfType([
61 | React.PropTypes.string,
62 | React.PropTypes.array
63 | ]),
64 | fields: PropTypes.array.isRequired,
65 | onChange: PropTypes.func.isRequired
66 | }
67 |
--------------------------------------------------------------------------------
/src/components/form_types/native.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import ReactDOM from 'react-dom';
3 |
4 | export default class Native extends Component {
5 |
6 | constructor() {
7 | super();
8 | this.change = this.change.bind(this);
9 | }
10 |
11 | change(e) {
12 | const { onChange, id } = this.props;
13 | onChange(id, e.target.value);
14 | }
15 |
16 | render() {
17 | const { placeholder, required, type, value } = this.props;
18 | return (
19 |
27 | );
28 | }
29 | }
30 |
31 | Native.propTypes = {
32 | id: PropTypes.string.isRequired,
33 | type: PropTypes.string.isRequired,
34 | value: React.PropTypes.oneOfType([
35 | React.PropTypes.string,
36 | React.PropTypes.number,
37 | React.PropTypes.bool
38 | ]),
39 | placeholder: PropTypes.string.isRequired,
40 | onChange: PropTypes.func.isRequired,
41 | required: PropTypes.bool
42 | }
43 |
--------------------------------------------------------------------------------
/src/components/form_types/radio.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { colN } from '../../utils';
4 |
5 | export default class Radio extends Component {
6 |
7 | constructor() {
8 | super();
9 | this.change = this.change.bind(this);
10 | }
11 |
12 | change(e) {
13 | const { onChange, id } = this.props;
14 | const obj = {};
15 | let val = e.target.id.replace(id + '-', '');
16 | if (val === 'true') val = true;
17 | if (val === 'false') val = false;
18 | onChange(id, val);
19 | }
20 |
21 | render() {
22 | const { id, fields, value } = this.props;
23 | const renderFields = function(field, i) {
24 | const n = colN(fields.length);
25 | return (
26 |
27 |
34 |
37 | {field.label}
38 |
39 |
40 | );
41 | }.bind(this);
42 |
43 | return (
44 |
45 | {fields.map(renderFields)}
46 |
47 | );
48 | }
49 | }
50 |
51 | Radio.propTypes = {
52 | id: PropTypes.string.isRequired,
53 | fields: PropTypes.array.isRequired,
54 | value: React.PropTypes.oneOfType([
55 | React.PropTypes.string,
56 | React.PropTypes.bool
57 | ]),
58 | onChange: PropTypes.func.isRequired
59 | }
60 |
--------------------------------------------------------------------------------
/src/components/form_types/select.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import ReactDOM from 'react-dom';
3 | import Autosuggest from 'react-autosuggest';
4 |
5 | export default class Select extends Component {
6 |
7 | constructor(props) {
8 | super();
9 |
10 | this.state = {
11 | inputValue: '',
12 | suggestions: props.options
13 | }
14 |
15 | this.change = this.change.bind(this);
16 | this.onInputChange = this.onInputChange.bind(this);
17 | this.remove = this.remove.bind(this);
18 | this.onSuggestionsUpdateRequested = this.onSuggestionsUpdateRequested.bind(this);
19 | }
20 |
21 | getSuggestions(v) {
22 | const { options, value } = this.props;
23 | v = v.trim().toLowerCase();
24 | const inputLength = v.length;
25 | return inputLength === 0 ? [] : options.filter(d =>
26 | d.label.toLowerCase().slice(0, inputLength) === v
27 | ).filter(d => {
28 | if (!value) return d;
29 | const contains = value.some(v => {
30 | return v.key === d.key
31 | });
32 | return !contains;
33 | });
34 | }
35 |
36 | onSuggestionsUpdateRequested({ value }) {
37 | this.setState({
38 | suggestions: this.getSuggestions(value)
39 | });
40 | }
41 |
42 | onInputChange(_, { newValue }) {
43 | this.setState({
44 | inputValue: newValue
45 | });
46 | }
47 |
48 | remove(e) {
49 | e.preventDefault();
50 | const { onChange, id, value } = this.props;
51 | const index = parseInt(e.target.getAttribute('data-index'), 10);
52 | let group = value ? value : [];
53 | group = group.filter((_, i) => i !== index);
54 | onChange(id, group);
55 | }
56 |
57 | change(e, v) {
58 | e.preventDefault();
59 | const { onChange, id, value } = this.props;
60 | let group = value ? value : [];
61 | group.push(v.suggestion);
62 | onChange(id, group);
63 |
64 | // Clear input value
65 | this.setState({inputValue: ''});
66 | }
67 |
68 | render() {
69 | const { id, value, placeholder } = this.props;
70 | const { inputValue, suggestions } = this.state;
71 | const addToList = function(field, i) {
72 | return (
73 |
80 |
87 |
88 | {field.label}
89 |
90 |
91 | );
92 | }.bind(this);
93 |
94 | const getSuggestionValue = function(d) {
95 | return d.label;
96 | };
97 |
98 | const renderSuggestion = function(d) {
99 | return (
100 | {d.label}
101 | )
102 | };
103 |
104 | return (
105 |
106 | {value && value.map(addToList)}
107 |
121 |
122 | );
123 | }
124 | }
125 |
126 | Select.propTypes = {
127 | id: PropTypes.string.isRequired,
128 | value: React.PropTypes.oneOfType([
129 | React.PropTypes.string,
130 | React.PropTypes.array
131 | ]),
132 | onChange: PropTypes.func.isRequired,
133 | options: PropTypes.array.isRequired,
134 | placeholder: PropTypes.string
135 | }
136 |
--------------------------------------------------------------------------------
/src/components/form_types/textarea.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import ReactDOM from 'react-dom';
3 |
4 | export default class Textarea extends Component {
5 |
6 | constructor() {
7 | super();
8 | this.change = this.change.bind(this);
9 | }
10 |
11 | change(e) {
12 | const { onChange, id } = this.props;
13 | onChange(id, e.target.value);
14 | }
15 |
16 | render() {
17 | const { placeholder, required, value } = this.props;
18 | return (
19 |
26 | );
27 | }
28 | }
29 |
30 | Textarea.propTypes = {
31 | id: PropTypes.string.isRequired,
32 | placeholder: PropTypes.string.isRequired,
33 | value: PropTypes.string.isRequired,
34 | onChange: PropTypes.func.isRequired,
35 | value: PropTypes.string,
36 | required: PropTypes.bool
37 | }
38 |
--------------------------------------------------------------------------------
/src/constants/action_types.js:
--------------------------------------------------------------------------------
1 | export const VALIDATORS = 'VALIDATORS';
2 | export const NORMALIZERS = 'NORMALIZERS';
3 | export const STATS_TEMPLATE = 'STATS_TEMPLATE';
4 | export const LISTING_TEMPLATE = 'LISTING_TEMPLATE';
5 | export const SORTS = 'SORTS';
6 | export const SORT_KEYS = 'SORT_KEYS';
7 | export const OPTIONS = 'OPTIONS';
8 | export const USER = 'USER';
9 | export const ACTOR = 'ACTOR';
10 | export const TEAM = 'TEAM';
11 | export const FILTER_LIST = 'FILTER_LIST';
12 | export const FORM = 'FORM';
13 | export const MESSAGE = 'MESSAGE';
14 | export const ERROR = 'ERROR';
15 | export const EVENTS = 'EVENTS';
16 | export const LOADING = 'LOADING';
17 |
--------------------------------------------------------------------------------
/src/containers/app.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { connect } from 'react-redux';
3 | import { Link, IndexLink } from 'react-router';
4 | import Modal from 'react-modal';
5 | import modalStyle from '../modal_style'
6 | import { bindActionCreators } from 'redux';
7 | import * as actions from '../actions';
8 |
9 | import ErrorDialog from '../components/error';
10 |
11 | class App extends Component {
12 | componentWillMount() {
13 | const { loadTeam, loadForm } = this.props;
14 | loadTeam();
15 | loadForm();
16 | }
17 |
18 | render() {
19 | const { children, directory, dismissModal, dismissError } = this.props;
20 | const { message, error, team, form, loading } = directory;
21 | const fetched = team && form;
22 | const loadClass = loading ? 'loading' : '';
23 |
24 | return (
25 |
26 |
27 |
28 |
29 |
33 | Team listing
34 |
35 |
39 | New member
40 |
41 |
42 |
43 |
44 | {fetched &&
45 | {children}
46 |
}
47 |
50 |
54 |
55 |
56 |
{message.title}
57 |
{message.content}
58 |
59 |
60 | {message.action}
61 |
62 |
63 |
64 |
65 | );
66 | }
67 | }
68 |
69 | App.propTypes = {
70 | children: PropTypes.node.isRequired,
71 | dismissModal: PropTypes.func.isRequired,
72 | dismissError: PropTypes.func.isRequired,
73 | loadTeam: PropTypes.func.isRequired,
74 | loadForm: PropTypes.func.isRequired,
75 | directory: PropTypes.object.isRequired,
76 | message: PropTypes.string
77 | };
78 |
79 | function mapStateToProps(state) {
80 | return state;
81 | }
82 |
83 | function mapDispatchToProps(dispatch) {
84 | return bindActionCreators(actions, dispatch);
85 | }
86 |
87 | export default connect(mapStateToProps, mapDispatchToProps)(App);
88 |
--------------------------------------------------------------------------------
/src/containers/edit.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { connect } from 'react-redux';
3 | import { bindActionCreators } from 'redux';
4 | import * as actions from '../actions';
5 | import { updatePath } from 'redux-simple-router';
6 |
7 | import DocumentTitle from 'react-document-title';
8 | import Form from '../components/form';
9 |
10 | class EditUser extends Component {
11 | componentWillMount() {
12 | const { directory, loadUser, reRoute, routeParams } = this.props;
13 | const { actor, options } = directory;
14 | const username = routeParams.user;
15 |
16 | if (!actor.admin) {
17 | if (username.toLowerCase() !== actor.login.toLowerCase()) reRoute(options.basePath + '404');
18 | }
19 |
20 | loadUser(username);
21 | }
22 |
23 | removeUser() {
24 | const { directory, removeUser, routeParams, setMessage, setError, reRoute } = this.props;
25 | const user = routeParams.user;
26 | const prompt = window.prompt('Are you sure? Enter their GitHub username to continue');
27 |
28 | if (prompt === user) {
29 | removeUser(user, (err) => {
30 | if (err) return setError(err);
31 | setMessage({
32 | title: `${user} removed.`,
33 | content: 'Record has been saved.',
34 | action: 'Okay',
35 | onClickHandler: () => {
36 | setMessage('');
37 | reRoute(directory.options.basePath);
38 | }
39 | });
40 | });
41 | } else {
42 | setError('GitHub account name was not entered correctly.');
43 | }
44 | }
45 |
46 | editUser(obj) {
47 | const { directory, updateUser, setMessage, setError, reRoute } = this.props;
48 | updateUser(obj, (err) => {
49 | if (err) return setError(err);
50 | setMessage({
51 | title: `${obj.github} updated!`,
52 | content: 'Record has been saved.',
53 | action: 'Okay',
54 | onClickHandler: () => {
55 | setMessage('');
56 | reRoute(directory.options.basePath);
57 | }
58 | });
59 | });
60 | }
61 |
62 | render() {
63 | const { directory, setError, routeParams } = this.props;
64 | const { validators, normalizers, team, actor, user, form } = directory;
65 | const parts = (form.length && user);
66 |
67 | return (
68 |
69 |
70 |
71 |
{`Edit ${routeParams.user}`}
72 |
73 | {parts ?
74 |
84 |
:
85 |
86 |
No form document found.
87 |
Check your configuration settings.
88 |
89 |
}
90 |
91 |
92 | );
93 | }
94 | }
95 |
96 | EditUser.propTypes = {
97 | directory: PropTypes.object.isRequired,
98 | reRoute: PropTypes.func.isRequired,
99 | routeParams: PropTypes.object.isRequired,
100 | updateUser: PropTypes.func.isRequired,
101 | loadUser: PropTypes.func.isRequired,
102 | removeUser: PropTypes.func.isRequired,
103 | setMessage: PropTypes.func.isRequired,
104 | setError: PropTypes.func.isRequired
105 | };
106 |
107 | function mapStateToProps(state) {
108 | return state;
109 | }
110 |
111 | function mapDispatchToProps(dispatch) {
112 | return bindActionCreators(Object.assign({}, actions, { reRoute: updatePath }), dispatch);
113 | }
114 |
115 | export default connect(mapStateToProps, mapDispatchToProps)(EditUser);
116 |
--------------------------------------------------------------------------------
/src/containers/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { connect } from 'react-redux';
3 | import { bindActionCreators } from 'redux';
4 | import { Link } from 'react-router';
5 | import * as actions from '../actions';
6 | import { csvBuffered } from 'json-csv';
7 | import VCard from 'vcf';
8 | import { saveAs } from 'filesaver.js';
9 | import { Base64 } from 'js-base64';
10 | import { updatePath } from 'redux-simple-router';
11 | import Filter from '../components/filter';
12 | import DocumentTitle from 'react-document-title';
13 | import Modal from 'react-modal';
14 | import modalStyle from '../modal_style'
15 |
16 | class Index extends Component {
17 |
18 | constructor(props, context) {
19 | super(props, context);
20 | this.state = { showStats: false };
21 | }
22 |
23 | showModal(e) {
24 | e.preventDefault();
25 | this.setState({ showStats: true });
26 | }
27 |
28 | dismissModal() {
29 | this.setState({ showStats: false });
30 | }
31 |
32 | downloadCSV(e) {
33 | e.preventDefault();
34 | const { team, form } = this.props.directory;
35 | let fields = [];
36 |
37 | // Build a header from the key values in form
38 | form.forEach((section) => {
39 | section.data.forEach((item) => {
40 | fields.push({
41 | name: item.key,
42 | label: item.label,
43 | filter: function(d) {
44 | if (typeof d === 'object' && d.length) {
45 | if (typeof d[0] === 'object') {
46 | var value = [];
47 | d.forEach(function(d) {
48 | if (d.name && d.value) {
49 | value.push(d.name + ': ' + d.value);
50 | } else {
51 | value.push(d.label);
52 | }
53 | })
54 | return value.join(', ');
55 | } else {
56 | return d.join(', ');
57 | }
58 | }
59 | return d;
60 | }
61 | });
62 | });
63 | });
64 |
65 | csvBuffered(team, {
66 | fields: fields
67 | }, function(err, csv) {
68 | if (err) return console.warn(err);
69 | saveAs(new Blob([csv], {
70 | type: 'text/csv;base64'
71 | }), 'team.csv');
72 | });
73 | }
74 |
75 | downloadContacts() {
76 | const { directory } = this.props;
77 | const card = [];
78 | directory.team.forEach((d) => {
79 | const nameIsAscii = (d.lname + d.fname).match(/[^ -~]/) === null;
80 | card.push((new VCard())
81 | .set('n', d.lname + ';' + d.fname, nameIsAscii ? {} : {charset: 'UTF-8'})
82 | .set('email', d.email)
83 | .set('org', directory.options.account)
84 | .set('tel', d.cell)
85 | .toString());
86 | });
87 | return 'data:text/vcard;charset=utf-8;base64,' + Base64.encode(card.join('\n'));
88 | }
89 |
90 | render() {
91 | const { directory, teamFilter, teamSort, reRoute, location} = this.props;
92 | const { team, filterList, sortKeys, actor, options, listingTemplate, statsTemplate } = directory;
93 | const bp = directory.options.basePath;
94 |
95 | return (
96 |
97 | {team.length ?
98 |
99 | {statsTemplate &&
103 | {statsTemplate(team)}
104 | }
105 |
106 | {(team.length > 1) &&
}
124 | {filterList.map((d, index) => {
125 | const access = (actor.admin || d.github.toLowerCase() === actor.login.toLowerCase()) ? true : false;
126 | return (
127 |
128 | {access &&
129 |
131 | Edit
132 |
133 |
}
134 | {listingTemplate(d)}
135 |
136 | );
137 | })}
138 |
139 |
:
140 |
No users.
141 |
142 | Create one?
143 |
144 |
}
145 |
146 | );
147 | }
148 | }
149 |
150 | Index.propTypes = {
151 | directory: PropTypes.object.isRequired,
152 | teamFilter: PropTypes.func.isRequired,
153 | teamSort: PropTypes.func.isRequired,
154 | location: PropTypes.object.isRequired,
155 | reRoute: PropTypes.func.isRequired
156 | };
157 |
158 | function mapStateToProps(state) {
159 | return state;
160 | }
161 |
162 | function mapDispatchToProps(dispatch) {
163 | return bindActionCreators(Object.assign({}, actions, { reRoute: updatePath }), dispatch);
164 | }
165 |
166 | export default connect(mapStateToProps, mapDispatchToProps)(Index);
167 |
--------------------------------------------------------------------------------
/src/containers/new.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { connect } from 'react-redux';
3 | import { bindActionCreators } from 'redux';
4 | import { updatePath } from 'redux-simple-router';
5 | import * as actions from '../actions';
6 | import DocumentTitle from 'react-document-title';
7 | import Form from '../components/form';
8 |
9 | class NewUser extends Component {
10 | addNewUser(obj) {
11 | const { directory, addUser, setMessage, setError, reRoute } = this.props;
12 | addUser(obj, (err) => {
13 | if (err) return setError(err);
14 | setMessage({
15 | title: `${obj.github} created!`,
16 | content: 'Record has been saved.',
17 | action: 'Okay',
18 | onClickHandler: () => {
19 | setMessage('');
20 | reRoute(directory.options.basePath);
21 | }
22 | });
23 | });
24 | }
25 |
26 | render() {
27 | const { directory, setError } = this.props;
28 | const { validators, normalizers, actor, team, form } = directory;
29 |
30 | return (
31 |
32 |
33 |
34 |
{`Create new member`}
35 |
36 | {directory.form.length ?
37 |
45 |
:
46 |
47 |
No form document found.
48 |
Check your configuration settings.
49 |
50 |
}
51 |
52 |
53 | );
54 | }
55 | }
56 |
57 | NewUser.propTypes = {
58 | directory: PropTypes.object.isRequired,
59 | addUser: PropTypes.func.isRequired,
60 | setMessage: PropTypes.func.isRequired,
61 | setError: PropTypes.func.isRequired,
62 | reRoute: PropTypes.func.isRequired
63 | };
64 |
65 | function mapStateToProps(state) {
66 | return state;
67 | }
68 |
69 | function mapDispatchToProps(dispatch) {
70 | return bindActionCreators(Object.assign({}, actions, { reRoute: updatePath }), dispatch);
71 | }
72 |
73 | export default connect(mapStateToProps, mapDispatchToProps)(NewUser);
74 |
--------------------------------------------------------------------------------
/src/containers/notfound.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { connect } from 'react-redux';
3 | import { bindActionCreators } from 'redux';
4 | import { Link } from 'react-router';
5 | import * as actions from '../actions';
6 | import DocumentTitle from 'react-document-title';
7 |
8 | export default class NotFound extends Component {
9 | render() {
10 | var { directory } = this.props
11 | return (
12 |
13 |
14 |
Page not found
15 |
18 | Back home?
19 |
20 |
21 |
22 | );
23 | }
24 | }
25 |
26 | NotFound.propTypes = {
27 | directory: PropTypes.object.isRequired,
28 | }
29 |
30 | function mapStateToProps(state) {
31 | return state;
32 | }
33 |
34 | function mapDispatchToProps(dispatch) {
35 | return bindActionCreators(Object.assign({}, actions), dispatch);
36 | }
37 |
38 | export default connect(mapStateToProps, mapDispatchToProps)(NotFound);
39 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import 'babel-core/polyfill';
2 |
3 | import React from 'react';
4 | import { render } from 'react-dom';
5 | import { Router, Route, IndexRoute } from 'react-router';
6 |
7 | import { createStore, combineReducers, applyMiddleware } from 'redux';
8 | import { Provider } from 'react-redux';
9 |
10 | import createHashHistory from 'history/lib/createHashHistory';
11 | import createBrowserHistory from 'history/lib/createBrowserHistory';
12 |
13 | import { syncReduxAndRouter, routeReducer } from 'redux-simple-router';
14 | import thunk from 'redux-thunk';
15 | import reducers from './reducers';
16 |
17 | import App from './containers/app';
18 | import Index from './containers/index';
19 | import Edit from './containers/edit';
20 | import New from './containers/new';
21 | import NotFound from './containers/notfound';
22 | import {
23 | setSorts,
24 | setOptions,
25 | setValidators,
26 | setNormalizers,
27 | setListingTemplate,
28 | setStatsTemplate,
29 | eventSubscribe
30 | } from './actions';
31 |
32 | const reducer = combineReducers(Object.assign({}, { directory: reducers }, {
33 | routing: routeReducer
34 | }));
35 |
36 | const store = applyMiddleware(thunk)(createStore)(reducer);
37 |
38 | export default class TeamDirectory {
39 | constructor(id, options) {
40 | options = options || {};
41 |
42 | const history = options.pushState ?
43 | createBrowserHistory() :
44 | createHashHistory({ queryKey: false });
45 |
46 | syncReduxAndRouter(history, store);
47 |
48 | // Sets options passed from client
49 | store.dispatch(setOptions(options));
50 |
51 | const container = typeof id === 'string' ?
52 | document.getElementById(id) : id;
53 |
54 | const basePath = options.basePath || '/';
55 |
56 | render(
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | ,
67 | container
68 | );
69 | }
70 |
71 | sorts(fn) {
72 | store.dispatch(setSorts(fn));
73 | return this;
74 | }
75 |
76 | validators(fn) {
77 | store.dispatch(setValidators(fn));
78 | return this;
79 | }
80 |
81 | normalizers(fn) {
82 | store.dispatch(setNormalizers(fn));
83 | return this;
84 | }
85 |
86 | listingTemplate(fn) {
87 | store.dispatch(setListingTemplate(fn));
88 | return this;
89 | }
90 |
91 | statsTemplate(fn) {
92 | store.dispatch(setStatsTemplate(fn));
93 | return this;
94 | }
95 |
96 | on(type, fn) {
97 | store.dispatch(eventSubscribe(type, fn));
98 | return this;
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/src/initial_state.js:
--------------------------------------------------------------------------------
1 | /*eslint-disable react/display-name */
2 | /*eslint-disable react/no-multi-comp */
3 |
4 | import React from 'react';
5 | import md5 from 'md5-jkmyers';
6 | import VCard from 'vcf';
7 | import { Base64 } from 'js-base64';
8 |
9 | const initialState = {
10 | message: '',
11 | error: '',
12 | options: {
13 | account: '',
14 | repo: '',
15 | team: '',
16 | form: '',
17 | branch: '',
18 | basePath: '/',
19 | pushState: false,
20 | filterKeys: ['github']
21 | },
22 | events: {},
23 | form: [],
24 | links: [],
25 | loading: true,
26 | actor: null,
27 | user: null,
28 | team: null,
29 | filterList: null
30 | };
31 |
32 | function vCard(d) {
33 | const nameIsAscii = (d.lname + d.fname).match(/[^ -~]/) === null;
34 | const card = (new VCard())
35 | .set('n', d.lname + ';' + d.fname, nameIsAscii ? {} : {charset: 'UTF-8'})
36 | .set('email', d.email)
37 | .set('tel', d.cell)
38 | .toString();
39 |
40 | return 'data:text/vcard;charset=utf-8;base64,' + Base64.encode(card);
41 | }
42 |
43 | const links = [{
44 | key: 'cell',
45 | icon: 'mobile',
46 | label: 'Cell',
47 | url: 'tel:'
48 | }, {
49 | key: 'email',
50 | icon: 'mail',
51 | label: 'Email',
52 | url: 'mailto:'
53 | }, {
54 | key: 'github',
55 | icon: 'github',
56 | label: 'GitHub',
57 | url: 'https://github.com/'
58 | }, {
59 | key: 'twitter',
60 | icon: 'twitter',
61 | label: 'Twitter',
62 | url: 'https://twitter.com/'
63 | }];
64 |
65 | initialState.validators = function(d, c) { return c(null); }; // no-op
66 | initialState.normalizers = function(d, c) { return c(d); }; // no-op
67 |
68 | initialState.sorts = [{
69 | key: 'name',
70 | sort: function(team) {
71 | return team.sort((a, b) => {
72 | a = (a.lname) ? a.lname.split(' ') : '';
73 | b = (b.lname) ? b.lname.split(' ') : '';
74 | a = a[1] ? a[1] : a[0];
75 | b = b[1] ? b[1] : b[0];
76 | return a.localeCompare(b);
77 | });
78 | }
79 | }, {
80 | key: 'date',
81 | sort: function(team) {
82 | return team.sort((a, b) => {
83 | a = new Date(a.birthday).getTime();
84 | b = new Date(b.birthday).getTime();
85 | return b - a;
86 | });
87 | }
88 | }];
89 |
90 | initialState.sortKeys = initialState.sorts.reduce((memo, sort) => {
91 | memo.push(sort.key);
92 | return memo;
93 | }, []);
94 |
95 | initialState.statsTemplate = function(team) {
96 | const f = team.filter((_) => {
97 | return _.sex === 'xx';
98 | }).length;
99 |
100 | const stats = [
101 | { name: 'Total team', value: team.length },
102 | { name: 'Women', value: (f / team.length * 100).toFixed(1) + '%' },
103 | { name: 'Men', value: ((team.length - f) / team.length * 100).toFixed(1) + '%' }
104 | ];
105 |
106 | return (
107 |
108 |
109 |
Team stats
110 |
111 |
112 | {stats.map((stat, i) => {
113 | return (
114 |
115 | {stat.name}
116 | {stat.value}
117 |
118 | );
119 | })}
120 |
121 |
122 | );
123 | };
124 |
125 | initialState.listingTemplate = function(d) {
126 | return (
127 |
128 |
129 |
132 |
133 | {`${d.fname} ${d.lname}`}
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 | Birthday {d.birthday}
143 |
144 |
148 |
149 |
150 | {links.map((l, i) => {
151 | return (d[l.key]) ? (
152 |
160 | ) : '';
161 | })}
162 |
163 |
164 |
165 |
166 | );
167 | };
168 |
169 | export default initialState;
170 |
--------------------------------------------------------------------------------
/src/modal_style.js:
--------------------------------------------------------------------------------
1 | export default {
2 | overlay: {
3 | backgroundColor:'rgba(0,0,0,0.5)',
4 | position: 'fixed',
5 | top: 0,
6 | left: 0,
7 | right: 0,
8 | bottom: 0
9 | },
10 | content: {
11 | background: '#fff',
12 | position: 'absolute',
13 | top: '0',
14 | right: '0',
15 | left: '0',
16 | padding: '0',
17 | bottom: 'auto',
18 | width: '400px',
19 | border: 'none',
20 | overflow: 'hidden',
21 | WebkitOverflowScrolling: 'touch',
22 | borderRadius: '3px',
23 | outline: 'none',
24 | marginTop: '40px',
25 | marginLeft: 'auto',
26 | marginRight: 'auto'
27 | }
28 | };
29 |
--------------------------------------------------------------------------------
/src/reducers/index.js:
--------------------------------------------------------------------------------
1 | import * as types from '../constants/action_types.js';
2 | import initialState from '../initial_state.js';
3 |
4 | export default function data(state = initialState, action) {
5 | switch (action.type) {
6 |
7 | case types.LISTING_TEMPLATE:
8 | return Object.assign({}, state, {
9 | listingTemplate: action.listingTemplate
10 | });
11 |
12 | case types.STATS_TEMPLATE:
13 | return Object.assign({}, state, {
14 | statsTemplate: action.statsTemplate
15 | });
16 |
17 | case types.VALIDATORS:
18 | return Object.assign({}, state, {
19 | validators: action.validators
20 | });
21 |
22 | case types.NORMALIZERS:
23 | return Object.assign({}, state, {
24 | normalizers: action.normalizers
25 | });
26 |
27 | case types.OPTIONS:
28 | return Object.assign({}, state, {
29 | options: Object.assign({}, state.options, action.options)
30 | });
31 |
32 | case types.ACTOR:
33 | return Object.assign({}, state, {
34 | actor: action.actor
35 | });
36 |
37 | case types.USER:
38 | return Object.assign({}, state, {
39 | user: action.user
40 | });
41 |
42 | case types.TEAM:
43 | return Object.assign({}, state, {
44 | team: action.team
45 | });
46 |
47 | case types.FILTER_LIST:
48 | return Object.assign({}, state, {
49 | filterList: action.filterList
50 | });
51 |
52 | case types.SORTS:
53 | return Object.assign({}, state, {
54 | sorts: action.sorts
55 | });
56 |
57 | case types.SORT_KEYS:
58 | return Object.assign({}, state, {
59 | sortKeys: action.sortKeys
60 | });
61 |
62 | case types.FORM:
63 | return Object.assign({}, state, {
64 | form: action.form
65 | });
66 |
67 | case types.MESSAGE:
68 | return Object.assign({}, state, {
69 | message: action.message
70 | });
71 |
72 | case types.ERROR:
73 | return Object.assign({}, state, {
74 | error: action.error
75 | });
76 |
77 | case types.EVENTS:
78 | return Object.assign({}, state, {
79 | events: action.events
80 | });
81 |
82 | case types.LOADING:
83 | return Object.assign({}, state, {
84 | loading: action.loading
85 | });
86 |
87 | default:
88 | return state;
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | var utils = {};
2 |
3 | utils.colN = function(n) {
4 | if (n === 2) return 6;
5 | if (n === 3) return 4;
6 | if (n === 4) return 3;
7 | if (n > 4 || n === 1) return 12;
8 | };
9 |
10 | module.exports = utils;
11 |
--------------------------------------------------------------------------------
/team.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "admin": true,
4 | "fname": "Tristen",
5 | "lname": "Brown",
6 | "email": "tristen.brown@gmail.com",
7 | "sex": "xy",
8 | "birthday": "1912-11-26",
9 | "office": "dc",
10 | "homeaddress": "3524A 14TH ST NW",
11 | "teams": [
12 | "business",
13 | "design",
14 | "engineering",
15 | "operations",
16 | "support"
17 | ],
18 | "twitter": "fallsemo",
19 | "github": "tristen",
20 | "foursquare": "fallsemo",
21 | "other-links": [
22 | {
23 | "name": "instagram",
24 | "value": "tristen"
25 | }
26 | ]
27 | },
28 | {
29 | "fname": "Freddie",
30 | "lname": "Hubbard",
31 | "email": "freddiehubbard@gmail.com",
32 | "sex": "xy",
33 | "birthday": "1938-10-01",
34 | "office": "sf",
35 | "homeaddress": "123 St",
36 | "teams": [
37 | "design"
38 | ],
39 | "twitter": "freddiehubbard",
40 | "github": "freddiehubbard",
41 | "foursquare": "freddiehubbard"
42 | },
43 | {
44 | "fname": "Miles",
45 | "lname": "Davis",
46 | "email": "milesdavis@gmail.com",
47 | "sex": "xy",
48 | "birthday": "1926-05-26",
49 | "call": "2025908953",
50 | "office": "dc",
51 | "homeaddress": "123 St",
52 | "teams": [
53 | "design"
54 | ],
55 | "twitter": "milesdavis",
56 | "github": "milesdavis",
57 | "foursquare": "milesdavis"
58 | },
59 | {
60 | "fname": "Ingrid",
61 | "lname": "Jensen",
62 | "email": "ingridJensen@gmail.com",
63 | "sex": "xx",
64 | "birthday": "1966-01-06",
65 | "office": "ayacucho",
66 | "teams": [
67 | "design"
68 | ],
69 | "twitter": "ingridjensen",
70 | "github": "ingridjensen",
71 | "foursquare": "ingridjensen"
72 | }
73 | ]
74 |
--------------------------------------------------------------------------------
/test/test.js:
--------------------------------------------------------------------------------
1 | var test = require('tape');
2 | var TeamDirectory = require('../');
3 |
4 | function createDirectory(config) {
5 |
6 | var options = {
7 | GitHubToken: process.env.GitHubToken,
8 | account: process.env.account,
9 | repo: process.env.repo,
10 | team: process.env.team,
11 | form: process.env.form
12 | }
13 |
14 | if (process.env.branch) options.branch = process.env.branch;
15 | var Directory = TeamDirectory(document.createElement('div'), Object.assign(options, config));
16 | return Directory;
17 | }
18 |
19 | test('initialize and pushState', function(t) {
20 | var directory = createDirectory({ pushState:true });
21 | t.plan(2);
22 |
23 | directory.on('load', function(e) {
24 | t.ok(e, 'data loaded');
25 | });
26 |
27 | t.notOk(window.location.hash, 'hash is not present in the url');
28 | });
29 |
30 | test('hash prefix', function(t) {
31 | var directory = createDirectory();
32 | t.ok(window.location.hash, 'hash is present in the url');
33 | t.end();
34 | });
35 |
36 | test('teamdirectory.sorts', function(t) {
37 | var directory = createDirectory();
38 | directory.sorts([]);
39 | t.ok(true, 'custom sort was dispatched');
40 | t.end();
41 | });
42 |
43 | // close the smokestack window once tests are complete
44 | test('shutdown', function(t) {
45 | t.end();
46 | setTimeout(function() {
47 | window.close();
48 | });
49 | });
50 |
--------------------------------------------------------------------------------