├── start.sh ├── .gitignore ├── about ├── both.png ├── search.png └── homepage.png ├── res └── empty_avatar.jpg ├── sample-profile.html ├── app.js ├── contacts.css ├── fdupdate.html ├── contacts.html ├── index.html ├── fdupdate.js ├── contacts.js ├── google-contacts.js └── README.md /start.sh: -------------------------------------------------------------------------------- 1 | python -m SimpleHTTPServer 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | github-api-sample-responses/ 2 | google-contacts-sample-responses/ 3 | -------------------------------------------------------------------------------- /about/both.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrienjoly/freelance-directory-client/HEAD/about/both.png -------------------------------------------------------------------------------- /about/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrienjoly/freelance-directory-client/HEAD/about/search.png -------------------------------------------------------------------------------- /about/homepage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrienjoly/freelance-directory-client/HEAD/about/homepage.png -------------------------------------------------------------------------------- /res/empty_avatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrienjoly/freelance-directory-client/HEAD/res/empty_avatar.jpg -------------------------------------------------------------------------------- /sample-profile.html: -------------------------------------------------------------------------------- 1 | 2 | #nodejs #meteorjs #reactjs #mongodb developer. 3 | rate: 120€/hour 4 | availability: half-time starting in mid-august 5 | preferences: remote work only 6 | 7 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ 2 | (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), 3 | m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) 4 | })(window,document,'script','https://www.google-analytics.com/analytics.js','ga'); 5 | ga('create', 'UA-1858235-20', 'auto'); 6 | ga('send', 'pageview'); 7 | -------------------------------------------------------------------------------- /contacts.css: -------------------------------------------------------------------------------- 1 | .contact { 2 | overflow: auto; /* to wrap floating elements */ 3 | background: #eee; 4 | margin: 10px; 5 | padding: 10px; 6 | } 7 | 8 | .contact .photo-container { 9 | float: right; 10 | width: 48px; 11 | height: 48px; 12 | background-image: url("res/empty_avatar.jpg"); 13 | background-size: cover; 14 | } 15 | 16 | .contact .photo { 17 | width: 100%; 18 | height: 100%; 19 | background-size: cover; 20 | } 21 | -------------------------------------------------------------------------------- /fdupdate.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Freelance Directory Update... 6 | 7 | 8 |

Looking for update commit...

9 |
10 |   
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /contacts.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 17 |
18 |
19 |
20 |     
21 | 22 | 23 | Test custom protocol handler 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Freelance Directory Client 6 | 16 | 17 | 18 |

📇 Freelance Directory

19 |

Find the right freelancer from your contacts, and keep their skills/availability/preferences up to date.

20 |

This free and open-source web app: 21 |

26 |

27 | Connect a Google Contacts account 28 |

Don't worry, your personal data stays in Google's servers, and your own computer only!

29 | Fork me on GitHub 30 | 31 | 32 | 33 | 34 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /fdupdate.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 3 | function parseFdUpdateParam(fdupdate) { 4 | var RE_FDUPDATE = /^[^\:]+\:([^\/]+)\/(.+)$/; 5 | var parts = RE_FDUPDATE.exec(fdupdate); 6 | return { 7 | email: parts[1], 8 | updUrl: parts[2] 9 | .replace('github.com', 'https://api.github.com/repos') 10 | .replace('/commit/', '/commits/') 11 | .replace(/\/$/, '') // remove trailing slash 12 | }; 13 | } 14 | 15 | function fetchUpdateContent(commitUrl, callback) { 16 | console.log('GET ' + commitUrl + '...'); 17 | query({ 18 | url: commitUrl, 19 | dataType: 'jsonp' 20 | }, function(err, json) { 21 | if (err) return callback(err); 22 | var dataUrl = json.data.files[0].contents_url; 23 | console.log('GET ' + dataUrl + '...'); 24 | query(dataUrl, function(err, json) { 25 | var profileInfo = !err && atob(json.content); 26 | callback(err, profileInfo); 27 | }); 28 | }); 29 | } 30 | 31 | function makeAccumulator(callback, data) { 32 | var allItems = []; 33 | return function accumulate(items) { 34 | if (!items) { 35 | callback(null, allItems, data); 36 | } else { 37 | allItems = allItems.concat(items); 38 | } 39 | }; 40 | } 41 | 42 | function searchContactByEmail(email, callback) { 43 | console.log('auth to google contacts...'); 44 | auth(function(err, token){ 45 | console.log('search by email:', email, '...'); 46 | searchFullContacts(token, email, makeAccumulator(callback, token)); 47 | }); 48 | } 49 | 50 | function appendCoucouToUser(token, userId) { 51 | fetchContact(token, userId, function(err, json) { 52 | console.log('appendCoucouToUser 1 =>', err || json); 53 | if (err) return; 54 | json.entry.content.$t += '\ncoucou!'; 55 | console.log('new user data:', json.entry); 56 | updateContact(token, userId, json, function(err, res) { 57 | console.log('appendCoucouToUser 2 =>', err || res); 58 | }); 59 | }); 60 | } 61 | 62 | var fdupdate = decodeURIComponent(window.location.href.split(/[\?\&]fdupdate=/)[1] || ''); 63 | console.log('fdupdate parameter:', fdupdate); 64 | if (fdupdate) { 65 | var update = parseFdUpdateParam(fdupdate); 66 | $('h1').text('Fetching update...'); 67 | fetchUpdateContent(update.updUrl, function(err, profileInfo){ 68 | if (err) { 69 | $('h1').text('Oops, I did not find a commit for this fdupdate!'); 70 | console.error(err); 71 | } else { 72 | $('h1').text('Store update?'); 73 | $('pre').text(profileInfo); 74 | searchContactByEmail(update.email, function(err, contacts, token){ 75 | if (!contacts || !contacts.length) { 76 | alert('warning: found no matching contact for this email address...'); 77 | // TODO: allow user to select contact manually (or to add it?) 78 | } else if (contacts.length > 1) { 79 | alert('warning: more than one contact matches this email address...'); 80 | // TODO: allow user to select contact manually (or to add it?) 81 | } else { 82 | console.log('=>contact:', contacts); 83 | /* 84 | var contactEmail = contacts[0].feed.id.$t; 85 | fetchContactByEmail(token, contactEmail, function(err, res){ 86 | console.log('=>contact:', res); 87 | console.log('=>entries:', res.feed.entry.map(function(entry){ 88 | var name = (entry.title || {}).$t; 89 | var notes = ((entry.content || {}).$t || '').replace(/\n/g, ' // '); 90 | return name || notes ? name + ' : ' + notes + '\n' : ''; 91 | }).join('')); 92 | }); 93 | */ 94 | // TODO: if user accepts => store update in corresponding google contact 95 | } 96 | }); 97 | } 98 | }); 99 | } else { 100 | $('h1').text('Oops, I can\'t find a valid fdupdate in this URL!'); 101 | } 102 | 103 | })(); 104 | -------------------------------------------------------------------------------- /contacts.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 3 | function getPhotoUrl(googleContactEntry) { 4 | for (var i in googleContactEntry.link) { 5 | var link = googleContactEntry.link[i]; 6 | if (link.rel.indexOf('photo') != -1) { 7 | return link.href; 8 | } 9 | } 10 | } 11 | 12 | function getJobs(googleContactEntry) { 13 | return (googleContactEntry.gd$organization || []).map(function(org) { 14 | return (org.gd$orgTitle || {}).$t + ' @ ' + (org.gd$orgName || {}).$t; 15 | }); 16 | } 17 | 18 | // used for displaying resulting contacts after a search 19 | function appendEntries(div, entries) { 20 | var token = this; 21 | div.innerHTML = div.innerHTML + entries.map(function(entry){ 22 | var contactId = (entry.id || {}).$t; 23 | var name = (entry.title || {}).$t; 24 | var email = ((entry.gd$email || [])[0] || {}).address || ''; 25 | var notes = ((entry.content || {}).$t || '').replace(/\n/g, '
\n'); 26 | var photoUrl = getPhotoUrl(entry); 27 | var jobs = getJobs(entry); 28 | photoUrl = photoUrl ? photoUrl + '&access_token=' + encodeURIComponent(token.access_token) : ''; 29 | return !(name || notes) ? '' : [ 30 | '
', 31 | '
', 32 | '
', 33 | '
', 34 | '

' + name + '

', 35 | '

' + email + '

', 36 | '', 37 | '

' + notes + '

', 38 | '
' 39 | ].join('\n'); 40 | }).join('\n'); 41 | } 42 | 43 | // used by the "backup" feature for rendering contacts in JSON 44 | function appendJsonEntries(div, entries) { 45 | div.innerHTML = div.innerHTML + entries.map(function(entry){ 46 | var fields = {}; 47 | Object.keys(entry).map(function(field) { 48 | if (entry[field].$t) { 49 | fields[field] = entry[field].$t; 50 | } 51 | }); 52 | return JSON.stringify(entry, null, ' ') + '\n'; 53 | }).join(''); 54 | } 55 | 56 | // used for displaying contacts on the page 57 | function makeAppender(div, appendFct) { 58 | div.innerHTML = ''; 59 | return function (json) { 60 | if (!json) { 61 | console.info('done! :-)'); 62 | } else { 63 | console.log('=>', json.feed.entry); 64 | appendFct(div, json.feed.entry || []); 65 | } 66 | }; 67 | } 68 | 69 | // binds functions to UI elements 70 | function bindUI(token) { 71 | var resultsDiv = document.getElementById('results'); 72 | var exportDiv = document.getElementById('export'); 73 | document.getElementById('logged').style.display = 'block'; 74 | document.getElementById('btnFetchAll').onclick = function(){ 75 | fetchAllContacts(token, makeAppender(resultsDiv, appendEntries.bind(token))); 76 | }; 77 | document.getElementById('btnBackup').onclick = function(){ 78 | backupAllContacts(token, makeAppender(exportDiv, appendJsonEntries)); 79 | }; 80 | document.getElementById('search').onsubmit = function(evt) { 81 | evt.preventDefault(); 82 | var q = document.getElementById('query').value; 83 | searchFullContacts(token, q, makeAppender(resultsDiv, appendEntries.bind(token))); 84 | }; 85 | } 86 | 87 | // inits the contacts API and UI on page load 88 | window.onload = function() { 89 | console.log('√ onload'); 90 | document.getElementById('btnRegisterProtocol').onclick = function() { 91 | var url = window.location.href.replace(/contacts\.html.*/, 'fdupdate.html') + '?fdupdate=%s'; 92 | console.log('btnRegisterProtocol:', url); 93 | // /!\ this may not work from localhost 94 | navigator.registerProtocolHandler('web+fdupdate', url, 'Freelance Directory Update'); 95 | } 96 | auth(function(err, token){ 97 | if (err) { 98 | console.error('auth =>', err); 99 | // TODO: redirect to home page, for login, or at least display feedback 100 | } else { 101 | console.log('√ auth', token); 102 | bindUI(token); 103 | } 104 | }); 105 | }; 106 | 107 | })(); 108 | -------------------------------------------------------------------------------- /google-contacts.js: -------------------------------------------------------------------------------- 1 | var CLIENT_ID = '847367303310-1cda1v65gotbpoqjehmhcc21dofjc00q.apps.googleusercontent.com'; 2 | // from https://console.developers.google.com/apis/credentials?project=open-1365 3 | 4 | function throwError(err) { 5 | console.error(err); 6 | throw err; 7 | } 8 | 9 | function authPopup(callback) { 10 | var config = { 11 | 'client_id': CLIENT_ID, 12 | 'scope': 'https://www.google.com/m8/feeds' 13 | }; 14 | gapi.auth.authorize(config, function(res) { 15 | console.log('gapi.auth.authorize =>', res); 16 | if (res.error) { 17 | alert(res.error); 18 | window.location.href = window.location.href.substr(0, window.location.href.lastIndexOf('/') + 1); 19 | } else { 20 | callback(null, gapi.auth.getToken()); 21 | } 22 | }); 23 | } 24 | 25 | function auth(callback) { 26 | var config = { 27 | 'immediate': true, 28 | 'client_id': CLIENT_ID, 29 | 'scope': 'https://www.google.com/m8/feeds' 30 | }; 31 | gapi.auth.authorize(config, function(res) { 32 | console.log('gapi.auth.authorize =>', res); 33 | if (res.error) { 34 | alert(res.error); 35 | window.location.href = window.location.href.substr(0, window.location.href.lastIndexOf('/') + 1); 36 | } else { 37 | callback(null, gapi.auth.getToken()); 38 | } 39 | }); 40 | } 41 | 42 | function query(param, callback) { 43 | $.ajax(param).done(callback.bind(null, null)).fail(callback); 44 | } 45 | 46 | function fetchAll(token, opt, handle) { 47 | var projection = opt.projection || 'full'; 48 | var path = '/m8/feeds/contacts/default/' + projection; 49 | // jquery-based implementation: 50 | var prefix = 'https://www.google.com'; 51 | var url = opt.url || (prefix + path + '?alt=json&max-results=1000&v=3.0&q=' + (opt.q ? encodeURIComponent(opt.q) : '')) 52 | query({ 53 | url: url, 54 | dataType: 'json', 55 | data: token 56 | }, function(err, json) { 57 | var next; 58 | json.feed.link.map(function(link){ 59 | if (link.rel == 'next') { 60 | next = link.href; 61 | } 62 | }); 63 | handle(json); 64 | if (next) { 65 | fetchAll(token, { url: next }, handle); 66 | } else { 67 | handle(); 68 | } 69 | }); 70 | /* 71 | // this implementation does not require jquery but is a bit slower: 72 | gapi.client.request(opt.url || { 73 | 'path': path, 74 | 'params': { 75 | 'alt': 'json', 76 | 'max-results': 1000, 77 | 'v': '3.0', 78 | 'q': opt.q ? encodeURIComponent(opt.q) : undefined 79 | } 80 | }).then(function(res){ 81 | var json = res.result; 82 | var next; 83 | json.feed.link.map(function(link){ 84 | if (link.rel == 'next') { 85 | next = link.href; 86 | } 87 | }); 88 | handle(json); 89 | if (next) { 90 | fetchAll(token, { url: next }, handle); 91 | } else { 92 | handle(); 93 | } 94 | }, throwError); 95 | */ 96 | } 97 | 98 | function fetchAllContacts(token, handler) { 99 | fetchAll(token, { projection: 'property-content' }, handler); 100 | } 101 | 102 | function backupAllContacts(token, handler) { 103 | fetchAll(token, { projection: 'full' }, handler); 104 | } 105 | 106 | function searchContacts(token, q, handler) { 107 | fetchAll(token, { projection: 'property-content', q: q }, handler); 108 | } 109 | 110 | function searchFullContacts(token, q, handler) { 111 | fetchAll(token, { projection: 'full', q: q }, handler); 112 | } 113 | 114 | function fetchContact(token, userId, callback) { 115 | var url = '/m8/feeds/contacts/default/full/' + encodeURIComponent('' + (userId || 0)); 116 | gapi.client.request({ 117 | 'path': url, 118 | 'params': {'alt': 'json'} 119 | }).then(function(json){ 120 | callback(null, json.result); 121 | }, callback || throwError); 122 | } 123 | 124 | function updateContact(token, userId, json, callback) { 125 | // use a PUT request to the same URL with updated json data to update the user 126 | // cf https://developers.google.com/google-apps/contacts/v3/?csw=1#Updating 127 | var url = '/m8/feeds/contacts/default/full/' + encodeURIComponent('' + (userId || 0)); 128 | // https://developers.google.com/api-client-library/javascript/reference/referencedocs#gapiclientrequest 129 | gapi.client.request({ 130 | 'path': url, 131 | 'method': 'PUT', 132 | 'params': {'alt': 'json'}, 133 | 'headers': {'ETag': '*', 'If-Match': '*'}, // needed to avoid error 400 Missing resource version ID 134 | 'body': json 135 | }).then(function(json){ 136 | callback(null, json.result); 137 | }, callback || throwError); 138 | } 139 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **WARNING: After you give it your permission, this script edits your Google Contacts** 2 | 3 | **=> [backup your contacts](https://www.google.com/contacts/u/0/?cplus=0#contacts), and use at your own risk!** 4 | 5 | # Freelance Directory Client 6 | 7 | *Problem statement: It's slow and boring to look for relevant (and available) freelancing friends, when we want to forward a mission to them.* 8 | 9 | ![Freelance Directory Client Screenshot](/about/both.png) 10 | 11 | Freelance Directory Client is a personal contacts directory in which I can add my freelance friends, look for relevant ones based on technologies, and subscribe to their updates of skills, preferences and availability (in order to update my directory). It's based on Google Contacts. 12 | 13 | - You can try it from: [adrienjoly.com/freelance-directory-client](https://adrienjoly.com/freelance-directory-client) 14 | - Sample profile, on GitHub: [adrienjoly/freelance-directory-profile](https://github.com/adrienjoly/freelance-directory-profile) (fork it and put your info!) 15 | - The story behing this project: [Side project #6 - Freelance Directory Client](https://www.getrevue.co/profile/aj-sideprojects/issues/side-project-6-freelance-directory-client-23842) 16 | - Source code: [this repository](https://github.com/adrienjoly/freelance-directory-client) 17 | 18 | ## FEATURES: Use cases 19 | 20 | - Find relevant and available Freelancers quickly from your contacts, based on the stack/skills of the mission that you want to forward **[WORKING]** 21 | - Edit the technical stack / skills, preferences, and availability of your Freelancing contacts, manually **[COMING SOON]** 22 | - Subscribe to your Freelancing contacts, to help you update their info (i.e. stack/skills, prefs, avail.) **[COMING SOON]** 23 | - As a Freelancer, publish your updates (i.e. stack/skills, prefs, avail.), so that your friends can integrate changes into their directory **[WORKING]** 24 | 25 | ## USAGE: Working features 26 | 27 | 1. Display and backup your contacts 28 | 29 | + When opening the page, give permission to access your Google Contacts. (*don't worry, I can't store any personal data because this app has no back-end server!*) 30 | + The "list contacts" button appends all your contacts on the page, to make sure that the app is connected to your Google Contacts account. 31 | + Click "backup contacts" button to generates a JSON export of your contacts, and appends it on the page. Then keep that JSON data safely in a file, just in case. :-) 32 | + Enter a word to search for contacts whose description contain that word. 33 | + Display a contact by id. (e.g. the first one's id is 0) 34 | 35 | 2. Update a contact's description 36 | 37 | + Append "Coucou" to that contact (i.e. use its id) 38 | + Display that contact again, to realize that the app has stored "Coucou" in the corresponding Google Contacts directory. => It's still displayed if you refresh the app. 39 | + You can now search for contacts that contain "Coucou" 40 | 41 | 3. Receive contact updates from external sources 42 | 43 | + Click the "Register protocol" button, so that `web+fdupdate://` URLs are transmitted to this app, at this URL. 44 | + Copy the URL of the "Test custom protocol handler" link. 45 | + Close the app's tab. 46 | + Open the copied URL (`web+fdupdate://...`) in a new tab => the app should open and display the content of the incoming update (a github commit) 47 | + In the future, you will be able to append up-to-date availability information from a freelancing friend into your corresponding Google Contact. This information will be stored in a Github commit, and pushed to you by email, in the form of a `web+fdupdate://...` link. :-) 48 | 49 | ## PRICING: Is this free to use? 50 | 51 | Freelance-directory-client is free, and will always be free to use, for several reasons: 52 | 53 | - As a tool that promotes sharing between freelancers and eases the process of our clients to find relevant talent, I'm willing to encourage my peers to use it, without financial barrier. 54 | - So far, I don't need to setup and maintain a server/back-end infrastructure for this app, so it costs me nothing (but a bit of time to maintain the code). 55 | 56 | So I'm happy to offer this product for free! :-) 57 | 58 | ...And, if I ever change my mind, you will still be able to fork this repo to keep using it for free! 59 | 60 | ## API: Syntax of custom update URLs 61 | 62 | Syntax: `web+fdupdate:/github.com///commit/` 63 | 64 | Sample URL: `web+fdupdate:adri@whyd.com/github.com/adrienjoly/freelance-directory-profile/commit/2987b22c22df618d464af5e44d0d5c32d28e21c2` 65 | 66 | The sample URL above informs `freelance-directory-client` that: 67 | 68 | - your friend Adrien whose email address is `adri@whyd.com` has updated his freelancing info; 69 | - this update is stored in a commit that can be found there: [github.com/adrienjoly/freelance-directory-profile/commit/2987b22c22df618d464af5e44d0d5c32d28e21c2](http://github.com/adrienjoly/freelance-directory-profile/commit/2987b22c22df618d464af5e44d0d5c32d28e21c2), which contains: 70 | 71 | ``` 72 | 73 | #nodejs #vuejs #reactjs #mongodb developer. 74 | rate: 120€/hour 75 | availability: half-time, but only for collaborations (not for paid missions), cf adrienjoly.com/now 76 | preferences: remote work only, cf contact page of adrienjoly.com 77 | 78 | ``` 79 | 80 | For instance, this update contains: 81 | 82 | - technologies that Adrien works on professionally, in the form of hashtags; 83 | - Adrien's current hourly rate; 84 | - his current availability; 85 | - and his current preferences. 86 | 87 | **If you want to publish your freelance profile, fork the [freelance-directory-profile](https://github.com/adrienjoly/freelance-directory-profile) repository in your own Github account, and fill it with your own info.** 88 | 89 | ## ROADMAP: Next steps 90 | 91 | - Actually store updates from custom URLs into the corresponding contact 92 | - Design an actual UI for the product 93 | - Design+copywriting: make an explanatory landing page with a Google Connect button 94 | 95 | ## SETUP: Forking instructions 96 | 97 | - After forking, don't forget to set your own Google Client id in `google-contacts.js` 98 | --------------------------------------------------------------------------------