├── .eslintcache ├── .eslintrc ├── .flowconfig ├── .gitignore ├── README.md ├── example.env ├── netlify.toml ├── package.json ├── public ├── _redirects ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── browserconfig.xml ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── gifv2.gif ├── index.html ├── manifest.json ├── mstile-144x144.png ├── mstile-150x150.png ├── mstile-310x150.png ├── mstile-310x310.png ├── mstile-70x70.png ├── safari-pinned-tab.svg ├── screenshot3.png ├── screenshot_home.png └── screenshot_home2.png ├── src ├── __tests__ │ └── App.test.js ├── actions │ ├── auth.js │ ├── bookmarks.js │ ├── filters.js │ ├── loading.js │ ├── menuState.js │ ├── tags.js │ └── users.js ├── algolia.js ├── api │ ├── bookmarks.js │ └── tags.js ├── components │ ├── Application.js │ ├── BookmarkListItem │ │ ├── index.js │ │ └── styles.js │ ├── BookmarksList │ │ ├── index.js │ │ └── styles.js │ ├── BookmarksPage │ │ ├── index.js │ │ └── styles.js │ ├── DebugComponent │ │ └── index.js │ ├── DrawerMenu │ │ ├── index.js │ │ └── styles.js │ ├── EditBookmarkForm │ │ ├── index.js │ │ └── styles.js │ ├── ExportFile │ │ └── index.js │ ├── ExportPage │ │ └── index.js │ ├── Icons │ │ └── index.js │ ├── Inputs │ │ ├── TagsInput.js │ │ └── index.js │ ├── Loading │ │ └── index.js │ ├── LoadingSpinner │ │ └── index.js │ ├── MainHeader │ │ ├── index.js │ │ └── styles.js │ ├── NewBookmarkForm │ │ ├── index.js │ │ └── styles.js │ ├── NewTagForm │ │ ├── index.js │ │ └── styles.js │ ├── SearchBar │ │ ├── index.js │ │ └── styles.js │ ├── Sidebar │ │ ├── index.js │ │ └── styles.js │ ├── SigninPage │ │ └── index.js │ └── TagsPage │ │ └── index.js ├── config.js ├── firebase.js ├── index.js ├── initial-state.js ├── lambda │ ├── algolia-add.js │ ├── algolia-delete.js │ └── get-title.js ├── reducers │ ├── auth.js │ ├── bookmarks.js │ ├── filters.js │ ├── index.js │ ├── loading.js │ ├── menuState.js │ ├── pagination.js │ └── tags.js ├── serviceWorker.js ├── store.js └── types.js └── yarn.lock /.eslintcache: -------------------------------------------------------------------------------- 1 | [{"/Users/thomas/javascript/opensource/better-bookmarks/src/index.js":"1","/Users/thomas/javascript/opensource/better-bookmarks/src/store.js":"2","/Users/thomas/javascript/opensource/better-bookmarks/src/serviceWorker.js":"3","/Users/thomas/javascript/opensource/better-bookmarks/src/actions/auth.js":"4","/Users/thomas/javascript/opensource/better-bookmarks/src/components/Application.js":"5","/Users/thomas/javascript/opensource/better-bookmarks/src/initial-state.js":"6","/Users/thomas/javascript/opensource/better-bookmarks/src/firebase.js":"7","/Users/thomas/javascript/opensource/better-bookmarks/src/reducers/index.js":"8","/Users/thomas/javascript/opensource/better-bookmarks/src/components/SigninPage/index.js":"9","/Users/thomas/javascript/opensource/better-bookmarks/src/components/Loading/index.js":"10","/Users/thomas/javascript/opensource/better-bookmarks/src/components/NewBookmarkForm/index.js":"11","/Users/thomas/javascript/opensource/better-bookmarks/src/components/EditBookmarkForm/index.js":"12","/Users/thomas/javascript/opensource/better-bookmarks/src/components/TagsPage/index.js":"13","/Users/thomas/javascript/opensource/better-bookmarks/src/components/BookmarksPage/index.js":"14","/Users/thomas/javascript/opensource/better-bookmarks/src/components/MainHeader/index.js":"15","/Users/thomas/javascript/opensource/better-bookmarks/src/components/ExportPage/index.js":"16","/Users/thomas/javascript/opensource/better-bookmarks/src/components/Sidebar/index.js":"17","/Users/thomas/javascript/opensource/better-bookmarks/src/components/DrawerMenu/index.js":"18","/Users/thomas/javascript/opensource/better-bookmarks/src/reducers/tags.js":"19","/Users/thomas/javascript/opensource/better-bookmarks/src/reducers/auth.js":"20","/Users/thomas/javascript/opensource/better-bookmarks/src/reducers/bookmarks.js":"21","/Users/thomas/javascript/opensource/better-bookmarks/src/reducers/filters.js":"22","/Users/thomas/javascript/opensource/better-bookmarks/src/reducers/loading.js":"23","/Users/thomas/javascript/opensource/better-bookmarks/src/reducers/menuState.js":"24","/Users/thomas/javascript/opensource/better-bookmarks/src/reducers/pagination.js":"25","/Users/thomas/javascript/opensource/better-bookmarks/src/config.js":"26","/Users/thomas/javascript/opensource/better-bookmarks/src/actions/bookmarks.js":"27","/Users/thomas/javascript/opensource/better-bookmarks/src/actions/tags.js":"28","/Users/thomas/javascript/opensource/better-bookmarks/src/components/NewBookmarkForm/styles.js":"29","/Users/thomas/javascript/opensource/better-bookmarks/src/types.js":"30","/Users/thomas/javascript/opensource/better-bookmarks/src/components/EditBookmarkForm/styles.js":"31","/Users/thomas/javascript/opensource/better-bookmarks/src/actions/filters.js":"32","/Users/thomas/javascript/opensource/better-bookmarks/src/actions/loading.js":"33","/Users/thomas/javascript/opensource/better-bookmarks/src/components/BookmarksPage/styles.js":"34","/Users/thomas/javascript/opensource/better-bookmarks/src/components/DrawerMenu/styles.js":"35","/Users/thomas/javascript/opensource/better-bookmarks/src/actions/menuState.js":"36","/Users/thomas/javascript/opensource/better-bookmarks/src/components/Sidebar/styles.js":"37","/Users/thomas/javascript/opensource/better-bookmarks/src/components/Inputs/TagsInput.js":"38","/Users/thomas/javascript/opensource/better-bookmarks/src/components/MainHeader/styles.js":"39","/Users/thomas/javascript/opensource/better-bookmarks/src/api/bookmarks.js":"40","/Users/thomas/javascript/opensource/better-bookmarks/src/components/Inputs/index.js":"41","/Users/thomas/javascript/opensource/better-bookmarks/src/components/NewTagForm/index.js":"42","/Users/thomas/javascript/opensource/better-bookmarks/src/components/LoadingSpinner/index.js":"43","/Users/thomas/javascript/opensource/better-bookmarks/src/components/BookmarksList/index.js":"44","/Users/thomas/javascript/opensource/better-bookmarks/src/components/Icons/index.js":"45","/Users/thomas/javascript/opensource/better-bookmarks/src/components/ExportFile/index.js":"46","/Users/thomas/javascript/opensource/better-bookmarks/src/components/SearchBar/index.js":"47","/Users/thomas/javascript/opensource/better-bookmarks/src/api/tags.js":"48","/Users/thomas/javascript/opensource/better-bookmarks/src/components/NewTagForm/styles.js":"49","/Users/thomas/javascript/opensource/better-bookmarks/src/components/BookmarksList/styles.js":"50","/Users/thomas/javascript/opensource/better-bookmarks/src/components/SearchBar/styles.js":"51","/Users/thomas/javascript/opensource/better-bookmarks/src/algolia.js":"52","/Users/thomas/javascript/opensource/better-bookmarks/src/components/BookmarkListItem/index.js":"53","/Users/thomas/javascript/opensource/better-bookmarks/src/components/BookmarkListItem/styles.js":"54"},{"size":578,"mtime":1561189953000,"results":"55","hashOfConfig":"56"},{"size":436,"mtime":1561189935000,"results":"57","hashOfConfig":"56"},{"size":4951,"mtime":1560157977000,"results":"58","hashOfConfig":"56"},{"size":1046,"mtime":1563556982000,"results":"59","hashOfConfig":"56"},{"size":2637,"mtime":1569258540000,"results":"60","hashOfConfig":"56"},{"size":324,"mtime":1545038753000,"results":"61","hashOfConfig":"56"},{"size":689,"mtime":1569261122000,"results":"62","hashOfConfig":"56"},{"size":590,"mtime":1545038753000,"results":"63","hashOfConfig":"56"},{"size":498,"mtime":1563609654000,"results":"64","hashOfConfig":"56"},{"size":2935,"mtime":1563609910000,"results":"65","hashOfConfig":"56"},{"size":5539,"mtime":1564234870000,"results":"66","hashOfConfig":"56"},{"size":3873,"mtime":1564234975000,"results":"67","hashOfConfig":"56"},{"size":1713,"mtime":1563609729000,"results":"68","hashOfConfig":"56"},{"size":1146,"mtime":1563611487000,"results":"69","hashOfConfig":"56"},{"size":1538,"mtime":1564234779000,"results":"70","hashOfConfig":"56"},{"size":1057,"mtime":1563612168000,"results":"71","hashOfConfig":"56"},{"size":2897,"mtime":1569261122000,"results":"72","hashOfConfig":"56"},{"size":1567,"mtime":1563611131000,"results":"73","hashOfConfig":"56"},{"size":525,"mtime":1569261092000,"results":"74","hashOfConfig":"56"},{"size":645,"mtime":1534499593000,"results":"75","hashOfConfig":"56"},{"size":1380,"mtime":1544869668000,"results":"76","hashOfConfig":"56"},{"size":339,"mtime":1544354726000,"results":"77","hashOfConfig":"56"},{"size":280,"mtime":1539074256000,"results":"78","hashOfConfig":"56"},{"size":206,"mtime":1545038753000,"results":"79","hashOfConfig":"56"},{"size":229,"mtime":1544776820000,"results":"80","hashOfConfig":"56"},{"size":70,"mtime":1546519250000,"results":"81","hashOfConfig":"56"},{"size":4928,"mtime":1569138027000,"results":"82","hashOfConfig":"56"},{"size":1122,"mtime":1569261122000,"results":"83","hashOfConfig":"56"},{"size":530,"mtime":1562503294000,"results":"84","hashOfConfig":"56"},{"size":378,"mtime":1563611086000,"results":"85","hashOfConfig":"56"},{"size":569,"mtime":1563035523000,"results":"86","hashOfConfig":"56"},{"size":223,"mtime":1545038753000,"results":"87","hashOfConfig":"56"},{"size":178,"mtime":1545038753000,"results":"88","hashOfConfig":"56"},{"size":110,"mtime":1561903281000,"results":"89","hashOfConfig":"56"},{"size":959,"mtime":1562506487000,"results":"90","hashOfConfig":"56"},{"size":90,"mtime":1545038753000,"results":"91","hashOfConfig":"56"},{"size":1340,"mtime":1560586826000,"results":"92","hashOfConfig":"56"},{"size":809,"mtime":1563557302000,"results":"93","hashOfConfig":"56"},{"size":1646,"mtime":1608642198000,"results":"94","hashOfConfig":"56"},{"size":816,"mtime":1563386553000,"results":"95","hashOfConfig":"56"},{"size":553,"mtime":1563122013000,"results":"96","hashOfConfig":"56"},{"size":1229,"mtime":1562504875000,"results":"97","hashOfConfig":"56"},{"size":1056,"mtime":1563610339000,"results":"98","hashOfConfig":"56"},{"size":1390,"mtime":1569138027000,"results":"99","hashOfConfig":"56"},{"size":1703,"mtime":1560586484000,"results":"100","hashOfConfig":"56"},{"size":1118,"mtime":1564234738000,"results":"101","hashOfConfig":"56"},{"size":1084,"mtime":1608639961000,"results":"102","hashOfConfig":"56"},{"size":375,"mtime":1563121675000,"results":"103","hashOfConfig":"56"},{"size":547,"mtime":1562504698000,"results":"104","hashOfConfig":"56"},{"size":781,"mtime":1562503127000,"results":"105","hashOfConfig":"56"},{"size":784,"mtime":1562507684000,"results":"106","hashOfConfig":"56"},{"size":230,"mtime":1608636089000,"results":"107","hashOfConfig":"56"},{"size":1645,"mtime":1563557191000,"results":"108","hashOfConfig":"56"},{"size":929,"mtime":1562507521000,"results":"109","hashOfConfig":"56"},{"filePath":"110","messages":"111","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"1x6pr7z",{"filePath":"112","messages":"113","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"114","messages":"115","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"116","messages":"117","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"118","messages":"119","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"120","messages":"121","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"122","messages":"123","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"124","messages":"125","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"126","messages":"127","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"128","messages":"129","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"130","messages":"131","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"132","messages":"133","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"134","messages":"135","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"136","messages":"137","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"138","messages":"139","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"140","messages":"141","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"142","messages":"143","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"144","messages":"145","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"146","messages":"147","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"148","messages":"149","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"150","messages":"151","errorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"152","messages":"153","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"154","messages":"155","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"156","messages":"157","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"158","messages":"159","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"160","messages":"161","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"162","messages":"163","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"164","messages":"165","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"166","messages":"167","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"168","messages":"169","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"170","messages":"171","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"172","messages":"173","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"174","messages":"175","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"176","messages":"177","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"178","messages":"179","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"180","messages":"181","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"182","messages":"183","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"184","messages":"185","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"186","messages":"187","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"188","messages":"189","errorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"190","messages":"191","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"192","messages":"193","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"194","messages":"195","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"196","messages":"197","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"198","messages":"199","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"200","messages":"201","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"202","messages":"203","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"204","messages":"205","errorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"206","messages":"207","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"208","messages":"209","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"210","messages":"211","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"212","messages":"213","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"214","messages":"215","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"216","messages":"217","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"/Users/thomas/javascript/opensource/better-bookmarks/src/index.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/store.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/serviceWorker.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/actions/auth.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/Application.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/initial-state.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/firebase.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/reducers/index.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/SigninPage/index.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/Loading/index.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/NewBookmarkForm/index.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/EditBookmarkForm/index.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/TagsPage/index.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/BookmarksPage/index.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/MainHeader/index.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/ExportPage/index.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/Sidebar/index.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/DrawerMenu/index.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/reducers/tags.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/reducers/auth.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/reducers/bookmarks.js",["218"],"/Users/thomas/javascript/opensource/better-bookmarks/src/reducers/filters.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/reducers/loading.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/reducers/menuState.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/reducers/pagination.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/config.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/actions/bookmarks.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/actions/tags.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/NewBookmarkForm/styles.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/types.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/EditBookmarkForm/styles.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/actions/filters.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/actions/loading.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/BookmarksPage/styles.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/DrawerMenu/styles.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/actions/menuState.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/Sidebar/styles.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/Inputs/TagsInput.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/MainHeader/styles.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/api/bookmarks.js",["219"],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/Inputs/index.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/NewTagForm/index.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/LoadingSpinner/index.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/BookmarksList/index.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/Icons/index.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/ExportFile/index.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/SearchBar/index.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/api/tags.js",["220"],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/NewTagForm/styles.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/BookmarksList/styles.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/SearchBar/styles.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/algolia.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/BookmarkListItem/index.js",[],"/Users/thomas/javascript/opensource/better-bookmarks/src/components/BookmarkListItem/styles.js",[],{"ruleId":"221","severity":1,"message":"222","line":44,"column":1,"nodeType":"223","endLine":59,"endColumn":3},{"ruleId":"221","severity":1,"message":"224","line":35,"column":1,"nodeType":"223","endLine":38,"endColumn":3},{"ruleId":"221","severity":1,"message":"224","line":18,"column":1,"nodeType":"223","endLine":20,"endColumn":3},"import/no-anonymous-default-export","Assign arrow function to a variable before exporting as module default","ExportDefaultDeclaration","Assign object to a variable before exporting as module default"] -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app" 3 | } 4 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | 5 | [libs] 6 | 7 | [lints] 8 | 9 | [options] 10 | 11 | [strict] 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | /lambda 12 | 13 | # misc 14 | .DS_Store 15 | .env.local 16 | .env.development.local 17 | .env.development 18 | .env.test.local 19 | .env.production.local 20 | .env 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Better Bookmarks 2 | 3 | A self-hosted bookmark manager made with React, Redux, Firebase (Cloud Firestore/ authentication), Netlify Functions and Algolia (search). 4 | 5 | This project uses several PWA features, including the [share target api.](https://developers.google.com/web/updates/2018/12/web-share-target) So after you've installed the app to your homescreen, you can share urls from your browser like you would with any other app (only tested on Android). 6 | 7 | ## Screenshots 8 | 9 | 10 | 11 | ![HOME](/public/screenshot3.png) 12 | 13 | ## Setup 14 | 15 | 1. Clone the repository 16 | 17 | 2. yarn install 18 | 19 | 3. Go to [firebase console](https://console.firebase.google.com/) and create a new project. 20 | 21 | 4. Click on the "web / add to webapp" icon to get the config keys for you project. Add them to your firebase.js config file. 22 | 23 | > Note: these are client-side keys and are not for security, but more for project identification. Make sure you add the proper security rules to secure your data. 24 | 25 | ```js 26 | const config = { 27 | apiKey: process.env.REACT_APP_API_KEY, 28 | authDomain: process.env.REACT_APP_DOMAIN, 29 | databaseURL: process.env.REACT_APP_DATABASE, 30 | projectId: process.env.REACT_APP_PROJECT_ID, 31 | storageBucket: process.env.REACT_APP_BUCKET, 32 | messagingSenderId: process.env.REACT_APP_MESSAGE_ID 33 | }; 34 | ``` 35 | 36 | 5. The next step is to create a Firestore database. You can do this by going to the database tab and create a database (Firestore) 37 | 38 | 6. When you have created the database, copy the following security rules into the rules section to secure your data. 39 | 40 | ```js 41 | service cloud.firestore { 42 | match /databases/{database}/documents { 43 | match /users/{userId} { 44 | allow create: if isSignedIn() && emailVerified(); 45 | allow update: if isSignedIn() && isOwner(userId); 46 | } 47 | 48 | match /users/{userId}/bookmarks/{bookmarkId} { 49 | allow write: if isSignedIn() && isOwner(userId); 50 | allow read: if isSignedIn() && isOwner(userId) 51 | } 52 | 53 | match /users/{userId}/tags/{tagId} { 54 | allow write: if isSignedIn() && isOwner(userId); 55 | allow read: if isSignedIn() && isOwner(userId) 56 | } 57 | 58 | function isSignedIn() { 59 | return request.auth != null; 60 | } 61 | function isOwner(userId) { 62 | return request.auth.uid == userId; 63 | } 64 | function emailVerified() { 65 | return request.auth.token.email_verified; 66 | } 67 | } 68 | } 69 | ``` 70 | 71 | 7. To enable authentication with Google, go to the navigation tab to add a login method --> select google option from the providers and switch the toggle to enable. If you scroll down, you can see the authorized domains for which this login method will work. This is important to update when you deploy your project. 72 | 73 | 8. You can now run `yarn start` to run the application locally. 74 | 75 | 9. The default query for fetching all bookmarks requires an index in firestore, so you might see this error in the console: 76 | 77 | `Uncaught (in promise) Error: The query requires an index. You can create it here:` 78 | 79 | follow the link to create the index. 80 | 81 | ## Netlify Functions & Deploying 82 | 83 | This project uses Netlify functions for additional functionality ( search indexing, getting titles from pages). See [src/lambda/](https://github.com/ThomasRoest/better-bookmarks/blob/master/src/lambda/). 84 | 85 | To deploy: 86 | 87 | - deploy your project with [Netlify](https://www.netlify.com) 88 | - Set your build environment variables 89 | - Update the authorized domains for authentication in the firebase console (authentication tab) 90 | 91 | ## Search with Algolia 92 | 93 | To configure search with [Algolia](https://www.algolia.com/) 94 | 95 | - signup for a free account on [Algolia.com](https://www.algolia.com/) 96 | - go to your dashboard and create a new app. 97 | - Go to api keys and create a new key with the following permissions: `search, addObject, deleteObject`. This will be the key for use with lambda functions. The other keys that you will need are the app ID to identify your app, and the search only key which will be used client-side to search your index. 98 | -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | REACT_APP_API_KEY= 2 | REACT_APP_DOMAIN= 3 | REACT_APP_DATABASE= 4 | REACT_APP_PROJECT_ID= 5 | REACT_APP_BUCKET= 6 | REACT_APP_MESSAGE_ID= 7 | 8 | REACT_APP_LAMBDA_ENDPOINT= 9 | REACT_APP_ALGOLIA_SEARCH_KEY= 10 | REACT_APP_ALGOLIA_APP_ID= 11 | ALGOLIA_API_KEY= -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | Functions = "lambda" -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "better-bookmarks", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "algoliasearch": "^4.8.3", 7 | "axios": "^0.21.1", 8 | "cheerio": "^1.0.0-rc.2", 9 | "date-fns": "^2.16.1", 10 | "dotenv": "^8.2.0", 11 | "file-saver": "^2.0.0-rc.3", 12 | "firebase": "^8.0.0", 13 | "react": "^17.0.1", 14 | "react-dom": "^17.0.1", 15 | "react-instantsearch-dom": "^6.8.2", 16 | "react-redux": "^7.0.0", 17 | "react-router-dom": "^5.0.1", 18 | "react-scripts": "3.0.1", 19 | "react-toastify": "^6.2.0", 20 | "redux": "^4.0.0", 21 | "redux-logger": "^3.0.6", 22 | "redux-thunk": "^2.3.0", 23 | "spectre.css": "^0.5.3", 24 | "styled-components": "^5.2.1" 25 | }, 26 | "scripts": { 27 | "start": "react-scripts start", 28 | "build": "react-scripts build && netlify-lambda build src/lambda", 29 | "test": "react-scripts test --env=jsdom", 30 | "eject": "react-scripts eject", 31 | "start:lambda": "netlify-lambda serve src/lambda", 32 | "build:lambda": "netlify-lambda build src/lambda" 33 | }, 34 | "devDependencies": { 35 | "flow-bin": "^0.141.0", 36 | "netlify-lambda": "^2.0.2" 37 | }, 38 | "browserslist": [ 39 | ">0.2%", 40 | "not dead", 41 | "not ie <= 11", 42 | "not op_mini all" 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 2 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThomasRoest/better-bookmarks/ec9d579abea92c029832c3cc01cf047037b3d747/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThomasRoest/better-bookmarks/ec9d579abea92c029832c3cc01cf047037b3d747/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThomasRoest/better-bookmarks/ec9d579abea92c029832c3cc01cf047037b3d747/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThomasRoest/better-bookmarks/ec9d579abea92c029832c3cc01cf047037b3d747/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThomasRoest/better-bookmarks/ec9d579abea92c029832c3cc01cf047037b3d747/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThomasRoest/better-bookmarks/ec9d579abea92c029832c3cc01cf047037b3d747/public/favicon.ico -------------------------------------------------------------------------------- /public/gifv2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThomasRoest/better-bookmarks/ec9d579abea92c029832c3cc01cf047037b3d747/public/gifv2.gif -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | 13 | 18 | 24 | 30 | 31 | 36 | 37 | 38 | 39 | 43 | Better Bookmarks 44 | 45 | 46 | 47 | 48 |
49 | 50 | 51 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Better Bookmarks", 3 | "name": "Better Bookmarks", 4 | "icons": [ 5 | { 6 | "src": "android-chrome-192x192.png", 7 | "type": "image/png", 8 | "sizes": "192x192" 9 | }, 10 | { 11 | "src": "android-chrome-512x512.png", 12 | "type": "image/png", 13 | "sizes": "512x512" 14 | } 15 | ], 16 | "start_url": ".", 17 | "display": "standalone", 18 | "theme_color": "#833582", 19 | "background_color": "#6678FF", 20 | "share_target": { 21 | "action": "bookmarks/new", 22 | "method": "GET", 23 | "enctype": "application/x-www-form-urlencoded", 24 | "params": { 25 | "title": "title", 26 | "url": "url", 27 | "text": "text" 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /public/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThomasRoest/better-bookmarks/ec9d579abea92c029832c3cc01cf047037b3d747/public/mstile-144x144.png -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThomasRoest/better-bookmarks/ec9d579abea92c029832c3cc01cf047037b3d747/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThomasRoest/better-bookmarks/ec9d579abea92c029832c3cc01cf047037b3d747/public/mstile-310x150.png -------------------------------------------------------------------------------- /public/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThomasRoest/better-bookmarks/ec9d579abea92c029832c3cc01cf047037b3d747/public/mstile-310x310.png -------------------------------------------------------------------------------- /public/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThomasRoest/better-bookmarks/ec9d579abea92c029832c3cc01cf047037b3d747/public/mstile-70x70.png -------------------------------------------------------------------------------- /public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 17 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /public/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThomasRoest/better-bookmarks/ec9d579abea92c029832c3cc01cf047037b3d747/public/screenshot3.png -------------------------------------------------------------------------------- /public/screenshot_home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThomasRoest/better-bookmarks/ec9d579abea92c029832c3cc01cf047037b3d747/public/screenshot_home.png -------------------------------------------------------------------------------- /public/screenshot_home2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThomasRoest/better-bookmarks/ec9d579abea92c029832c3cc01cf047037b3d747/public/screenshot_home2.png -------------------------------------------------------------------------------- /src/__tests__/App.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import Application from "../components/Application"; 4 | import { Provider } from "react-redux"; 5 | import { store } from "../store"; 6 | 7 | it("renders without crashing", () => { 8 | const div = document.createElement("div"); 9 | ReactDOM.render( 10 | 11 | 12 | , 13 | div 14 | ); 15 | ReactDOM.unmountComponentAtNode(div); 16 | }); 17 | 18 | // it('renders without crashing' ) 19 | -------------------------------------------------------------------------------- /src/actions/auth.js: -------------------------------------------------------------------------------- 1 | import { auth, googleAuthProvider, firestore } from "../firebase"; 2 | 3 | const userRef = firestore.collection("users"); 4 | 5 | export const signIn = () => { 6 | return dispatch => { 7 | dispatch({ type: "ATTEMPTING_LOGIN" }); 8 | auth.signInWithPopup(googleAuthProvider); 9 | }; 10 | }; 11 | 12 | export const signOut = () => { 13 | return dispatch => { 14 | dispatch({ type: "ATTEMPTING_LOGIN" }); 15 | auth.signOut(); 16 | }; 17 | }; 18 | 19 | const signedIn = user => { 20 | return { 21 | type: "SIGN_IN", 22 | email: user.email, 23 | displayName: user.displayName, 24 | photoURL: user.photoURL, 25 | uid: user.uid 26 | }; 27 | }; 28 | 29 | const signedOut = () => { 30 | return { 31 | type: "SIGN_OUT" 32 | }; 33 | }; 34 | 35 | export const startListeningToAuthChanges = () => { 36 | return dispatch => { 37 | auth.onAuthStateChanged(user => { 38 | if (user) { 39 | dispatch(signedIn(user)); 40 | userRef.doc(user.uid).set({ 41 | displayName: user.displayName, 42 | photoURL: user.photoURL, 43 | email: user.email, 44 | uid: user.uid 45 | }); 46 | } else { 47 | dispatch(signedOut()); 48 | } 49 | }); 50 | }; 51 | }; 52 | -------------------------------------------------------------------------------- /src/actions/bookmarks.js: -------------------------------------------------------------------------------- 1 | import { firestore } from "../firebase"; 2 | import axios from "axios"; 3 | import { LAMBDA_ENDPOINT } from "../config"; 4 | 5 | export const setBookmarks = bookmarks => { 6 | return { 7 | type: "SET_BOOKMARKS", 8 | payload: bookmarks 9 | }; 10 | }; 11 | 12 | export const paginateBookmarks = bookmarks => { 13 | return { 14 | type: "PAGINATE_BOOKMARKS", 15 | payload: bookmarks 16 | }; 17 | }; 18 | 19 | export const addBookmark = (id, data) => { 20 | return { 21 | type: "ADD_BOOKMARK", 22 | payload: { 23 | id: id, 24 | title: data.title, 25 | url: data.url, 26 | userId: data.userId, 27 | searchTerms: data.searchTerms 28 | } 29 | }; 30 | }; 31 | 32 | export const createAlgoliaItem = (id, data) => { 33 | const bookmark = { id, ...data }; 34 | return dispatch => { 35 | axios.post(`${LAMBDA_ENDPOINT}algolia-add`, JSON.stringify(bookmark)); 36 | }; 37 | }; 38 | 39 | export const deleteAlgoliaItem = (id, userId) => { 40 | const data = { id, userId }; 41 | return dispatch => { 42 | axios.post(`${LAMBDA_ENDPOINT}algolia-delete`, JSON.stringify(data)); 43 | }; 44 | }; 45 | 46 | export const bookmarkDeleted = id => { 47 | return { 48 | type: "REMOVE_BOOKMARK", 49 | payload: { id } 50 | }; 51 | }; 52 | 53 | export const bookmarkFetched = (id, data) => { 54 | return { 55 | type: "BOOKMARK_FETCHED", 56 | payload: { id, ...data } 57 | }; 58 | }; 59 | 60 | export const setLastbookmark = lastbookmark => { 61 | return { 62 | type: "SET_LAST_BOOKMARK", 63 | lastbookmark 64 | }; 65 | }; 66 | 67 | export const fetchBookmarks = userId => { 68 | const bookmarkRef = firestore 69 | .collection(`users/${userId}/bookmarks`) 70 | .orderBy("pinned", "desc") 71 | .orderBy("createdAt", "desc") 72 | .limit(10); 73 | return dispatch => { 74 | dispatch({ type: "LOADING_START" }); 75 | 76 | bookmarkRef.get().then(querySnapshot => { 77 | const newbookmarks = querySnapshot.docs.map(doc => { 78 | return { id: doc.id, ...doc.data() }; 79 | }); 80 | 81 | dispatch({ type: "LOADING_FINISHED" }); 82 | 83 | if (newbookmarks.length >= 1) { 84 | dispatch(setBookmarks(newbookmarks)); 85 | dispatch(setLastbookmark(newbookmarks[newbookmarks.length - 1])); 86 | } 87 | }); 88 | }; 89 | }; 90 | 91 | export const queryByTag = (userId, tag) => { 92 | const bookmarkRef = firestore 93 | .collection(`users/${userId}/bookmarks`) 94 | .where("tag", "==", tag); 95 | 96 | return dispatch => { 97 | dispatch({ type: "LOADING_START" }); 98 | 99 | bookmarkRef.get().then(querySnapshot => { 100 | const newbookmarks = querySnapshot.docs.map(doc => { 101 | return { id: doc.id, ...doc.data() }; 102 | }); 103 | 104 | dispatch({ type: "LOADING_FINISHED" }); 105 | dispatch(setBookmarks(newbookmarks)); 106 | }); 107 | }; 108 | }; 109 | 110 | export const searchQuery = (userId, query) => { 111 | const bookmarkRef = firestore 112 | .collection(`users/${userId}/bookmarks`) 113 | .where("searchTerms", "array-contains", query); 114 | 115 | return dispatch => { 116 | dispatch({ type: "LOADING_START" }); 117 | 118 | bookmarkRef.get().then(querySnapshot => { 119 | const newbookmarks = querySnapshot.docs.map(doc => { 120 | return { id: doc.id, ...doc.data() }; 121 | }); 122 | 123 | dispatch({ type: "LOADING_FINISHED" }); 124 | dispatch(setBookmarks(newbookmarks)); 125 | }); 126 | }; 127 | }; 128 | 129 | export const loadMore = (userId, lastbookmark) => { 130 | const bookmarkRef = firestore 131 | .collection(`users/${userId}/bookmarks`) 132 | .where("pinned", "==", false) 133 | .orderBy("createdAt", "desc") 134 | .startAfter(lastbookmark) 135 | .limit(10); 136 | return dispatch => { 137 | dispatch({ type: "LOADING_START" }); 138 | 139 | bookmarkRef.get().then(querySnapshot => { 140 | const newbookmarks = querySnapshot.docs.map(doc => { 141 | return { id: doc.id, ...doc.data() }; 142 | }); 143 | 144 | dispatch({ type: "LOADING_FINISHED" }); 145 | 146 | if (newbookmarks.length >= 1) { 147 | dispatch(paginateBookmarks(newbookmarks)); 148 | dispatch(setLastbookmark(newbookmarks[newbookmarks.length - 1])); 149 | } 150 | }); 151 | }; 152 | }; 153 | 154 | export const createBookmark = bookmark => { 155 | const bookmarkRef = firestore.collection( 156 | `users/${bookmark.userId}/bookmarks` 157 | ); 158 | return dispatch => { 159 | bookmarkRef 160 | .add(bookmark) 161 | .then(docRef => { 162 | dispatch(addBookmark(docRef.id, bookmark)); 163 | dispatch(createAlgoliaItem(docRef.id, bookmark)); 164 | }) 165 | .catch(error => { 166 | console.log(error); 167 | }); 168 | }; 169 | }; 170 | 171 | export const deleteBookmark = (id, userId) => { 172 | const bookmarkRef = firestore.collection(`users/${userId}/bookmarks`); 173 | return dispatch => { 174 | bookmarkRef 175 | .doc(id) 176 | .delete() 177 | .then(() => { 178 | dispatch(bookmarkDeleted(id)); 179 | }); 180 | dispatch(deleteAlgoliaItem(id, userId)); 181 | }; 182 | }; 183 | 184 | export const fetchAllBookmarks = userId => { 185 | console.log("fetching all bookmarks"); 186 | const bookmarkRef = firestore 187 | .collection(`users/${userId}/bookmarks`) 188 | .orderBy("createdAt", "desc"); 189 | return dispatch => { 190 | dispatch({ type: "LOADING_START" }); 191 | 192 | bookmarkRef.get().then(querySnapshot => { 193 | const newbookmarks = querySnapshot.docs.map(doc => { 194 | return { id: doc.id, ...doc.data() }; 195 | }); 196 | 197 | dispatch({ type: "LOADING_FINISHED" }); 198 | dispatch(setBookmarks(newbookmarks)); 199 | }); 200 | }; 201 | }; 202 | -------------------------------------------------------------------------------- /src/actions/filters.js: -------------------------------------------------------------------------------- 1 | //@flow 2 | 3 | export const setFilter = (tag: string) => { 4 | return { 5 | type: "SET_FILTER", 6 | tag 7 | }; 8 | }; 9 | 10 | export const setSearchTerm = (searchTerm: string) => { 11 | return { 12 | type: "SET_SEARCH_TERM", 13 | searchTerm 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /src/actions/loading.js: -------------------------------------------------------------------------------- 1 | //@flow 2 | 3 | export const loadingStart = () => { 4 | return { 5 | type: "LOADING_START" 6 | }; 7 | }; 8 | 9 | export const loadingFinished = () => { 10 | return { 11 | type: "LOADING_FINISHED" 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /src/actions/menuState.js: -------------------------------------------------------------------------------- 1 | export const toggleDrawerMenu = () => { 2 | return { 3 | type: "TOGGLE_DRAWER_MENU" 4 | }; 5 | }; 6 | -------------------------------------------------------------------------------- /src/actions/tags.js: -------------------------------------------------------------------------------- 1 | import { firestore } from "../firebase"; 2 | 3 | export const addTag = (id, tag) => { 4 | return { 5 | type: "ADD_TAG", 6 | tag: { id, ...tag } 7 | }; 8 | }; 9 | 10 | export const setTags = tags => { 11 | return { 12 | type: "SET_TAGS", 13 | tags 14 | }; 15 | }; 16 | 17 | export const tagDeleted = id => { 18 | return { 19 | type: "REMOVE_TAG", 20 | id 21 | }; 22 | }; 23 | 24 | export const fetchTags = userId => { 25 | const tagsRef = firestore.collection(`users/${userId}/tags`).orderBy("title"); 26 | return dispatch => { 27 | tagsRef.get().then(querySnapshot => { 28 | const newTags = querySnapshot.docs.map(doc => { 29 | return { id: doc.id, ...doc.data() }; 30 | }); 31 | dispatch(setTags(newTags)); 32 | }); 33 | }; 34 | }; 35 | 36 | export const createTag = tag => { 37 | const tagsRef = firestore.collection(`users/${tag.userId}/tags`); 38 | return dispatch => { 39 | tagsRef.add(tag).then(docRef => { 40 | dispatch(addTag(docRef.id, tag)); 41 | }); 42 | }; 43 | }; 44 | 45 | export const deleteTag = (id, userId) => { 46 | const tagsRef = firestore.collection(`users/${userId}/tags`); 47 | return dispatch => { 48 | tagsRef 49 | .doc(id) 50 | .delete() 51 | .then(() => { 52 | dispatch(tagDeleted(id)); 53 | }); 54 | }; 55 | }; 56 | -------------------------------------------------------------------------------- /src/actions/users.js: -------------------------------------------------------------------------------- 1 | import { firestore } from "../firebase"; 2 | 3 | const userRef = firestore.collection("users"); 4 | 5 | export const addUser = user => { 6 | return { 7 | type: "ADD_USER", 8 | displayName: user.displayName, 9 | uid: user.uid, 10 | photoURL: user.photoURL 11 | }; 12 | }; 13 | 14 | export const startListeningForUsers = () => { 15 | return dispatch => { 16 | userRef.onSnapshot(snapshot => { 17 | snapshot.docChanges().forEach(change => { 18 | dispatch(addUser(change.doc.data())); 19 | }); 20 | }); 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /src/algolia.js: -------------------------------------------------------------------------------- 1 | import algoliasearch from "algoliasearch/lite"; 2 | 3 | const appId = process.env.REACT_APP_ALGOLIA_APP_ID; 4 | const searchKey = process.env.REACT_APP_ALGOLIA_SEARCH_KEY; 5 | 6 | export const algoliaSearchClient = algoliasearch(appId, searchKey); 7 | -------------------------------------------------------------------------------- /src/api/bookmarks.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { firestore } from "../firebase"; 4 | import { IBookmark } from "../types"; 5 | 6 | export const fetchBookmark = (id: number, uid: string) => { 7 | return firestore 8 | .collection(`users/${uid}/bookmarks`) 9 | .doc(id) 10 | .get() 11 | .then(doc => { 12 | if (doc.exists) { 13 | return { id: doc.id, ...doc.data() }; 14 | } else { 15 | return; 16 | } 17 | }) 18 | .catch(error => { 19 | console.log("Error getting document:", error); 20 | }); 21 | }; 22 | 23 | export const updateBookmark = (bookmark: IBookmark) => { 24 | const bookmarkRef = firestore.collection( 25 | `users/${bookmark.userId}/bookmarks` 26 | ); 27 | return bookmarkRef.doc(bookmark.id).update({ 28 | title: bookmark.title, 29 | url: bookmark.url, 30 | tag: bookmark.tag, 31 | pinned: bookmark.pinned 32 | }); 33 | }; 34 | 35 | export default { 36 | fetchBookmark, 37 | updateBookmark 38 | }; 39 | -------------------------------------------------------------------------------- /src/api/tags.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { firestore } from "../firebase"; 4 | 5 | export const fetchTags = (uid: string) => { 6 | return firestore 7 | .collection(`users/${uid}/tags`) 8 | .get() 9 | .then(querySnapshot => { 10 | const tags = []; 11 | querySnapshot.forEach(doc => { 12 | tags.push({ id: doc.id, ...doc.data() }); 13 | }); 14 | return tags; 15 | }); 16 | }; 17 | 18 | export default { 19 | fetchTags 20 | }; 21 | -------------------------------------------------------------------------------- /src/components/Application.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from "react"; 4 | import styled from "styled-components"; 5 | import { connect } from "react-redux"; 6 | import { BrowserRouter as Router, Route, Switch } from "react-router-dom"; 7 | import { ToastContainer } from "react-toastify"; 8 | import "react-toastify/dist/ReactToastify.css"; 9 | import { signIn } from "../actions/auth"; 10 | import SignIn from "../components/SigninPage"; 11 | import Loading from "../components/Loading"; 12 | import NewBookmarkForm from "./NewBookmarkForm"; 13 | import EditBookmarkForm from "../components/EditBookmarkForm"; 14 | import MainHeader from "../components/MainHeader"; 15 | import TagsPage from "../components/TagsPage"; 16 | import BookmarksPage from "../components/BookmarksPage"; 17 | import ExportPage from "../components/ExportPage"; 18 | import Sidebar from "./Sidebar"; 19 | import DrawerMenu from "../components/DrawerMenu"; 20 | import { createGlobalStyle } from "styled-components"; 21 | 22 | const GlobalStyle = createGlobalStyle` 23 | body { 24 | background-color: #fafafa; 25 | font-size: 16px; 26 | } 27 | `; 28 | 29 | const FlexContainer = styled.div` 30 | @media (min-width: 579px) { 31 | display: flex; 32 | max-width: 1000px; 33 | margin: 0 auto; 34 | } 35 | `; 36 | 37 | const Main = styled.main` 38 | flex: 1 1 85%; 39 | background-color: #fafafa; 40 | `; 41 | 42 | const Application = ({ auth, signIn, menuIsOpen }) => ( 43 | 44 | 45 | {auth.status === "ANONYMOUS" && } 46 | {auth.status === "AWAITING_AUTH_RESPONSE" && } 47 | {auth.status === "SIGNED_IN" && ( 48 | 49 | 50 | {menuIsOpen && } 51 | 52 | 53 |
54 | 55 | 56 | 57 | 62 | 67 | 68 | 69 | 70 |
71 |
72 |
73 | )} 74 | 75 |
76 |
77 | ); 78 | 79 | const mapStateToProps = ({ auth, menuIsOpen }) => { 80 | return { 81 | auth: auth, 82 | menuIsOpen 83 | }; 84 | }; 85 | 86 | const mapDispatchToProps = dispatch => { 87 | return { 88 | signIn() { 89 | dispatch(signIn()); 90 | } 91 | }; 92 | }; 93 | 94 | export default connect( 95 | mapStateToProps, 96 | mapDispatchToProps 97 | )(Application); 98 | -------------------------------------------------------------------------------- /src/components/BookmarkListItem/index.js: -------------------------------------------------------------------------------- 1 | //@flow 2 | 3 | import React, { useState } from "react"; 4 | import { Link } from "react-router-dom"; 5 | import { StyledListItem, Actions, BookmarkInfo, Tag } from "./styles"; 6 | 7 | type IProps = { 8 | id: string, 9 | title: string, 10 | url: string, 11 | tag: string, 12 | userId: string, 13 | deleteBookmark: (id: string, userId: string) => void, 14 | pinned: boolean 15 | }; 16 | 17 | const BookmarkListItem = (props: IProps) => { 18 | const [editView, setEditView] = useState(false); 19 | 20 | const handleClick = () => { 21 | setEditView(!editView); 22 | }; 23 | 24 | const { id, title, url, tag, deleteBookmark, userId, pinned } = props; 25 | return ( 26 | 27 | {editView && ( 28 | 29 | 30 | 31 | Edit 32 | 33 | 39 | 40 | 43 | 44 | )} 45 | {!editView && ( 46 | 47 | 48 | {title} 49 | 50 | {tag} 51 | 52 | 53 | {pinned && ( 54 | pinned 55 | )} 56 | 57 | 58 | 59 | )} 60 | 61 | ); 62 | }; 63 | 64 | export default BookmarkListItem; 65 | -------------------------------------------------------------------------------- /src/components/BookmarkListItem/styles.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const Tag = styled.span` 4 | margin-left: 10px; 5 | `; 6 | 7 | export const StyledListItem = styled.li` 8 | background-color: white; 9 | list-style-type: none; 10 | display: flex; 11 | justify-content: space-between; 12 | padding: 0.2rem 0.6rem; 13 | border-bottom: 1px solid lightgray; 14 | 15 | a { 16 | color: #5755d9; 17 | &:visited { 18 | color: #5755d9; 19 | } 20 | } 21 | 22 | .icon-more-vert { 23 | padding: 1em; 24 | transition: background-color 0.3s ease; 25 | &:hover { 26 | background-color: lightgrey; 27 | cursor: pointer; 28 | transition: background-color 0.3s ease; 29 | } 30 | } 31 | 32 | @media (min-width: 600px) { 33 | padding: 0.4rem 1rem; 34 | } 35 | `; 36 | 37 | export const BookmarkInfo = styled.div` 38 | overflow: hidden; 39 | .label { 40 | font-size: 0.8em; 41 | margin-right: 5px; 42 | } 43 | `; 44 | 45 | export const Actions = styled.div` 46 | background: white; 47 | padding-left: 5px; 48 | flex: 1 1 auto; 49 | display: flex; 50 | justify-content: space-around; 51 | `; 52 | -------------------------------------------------------------------------------- /src/components/BookmarksList/index.js: -------------------------------------------------------------------------------- 1 | //@flow 2 | 3 | import React from "react"; 4 | import BookmarkListItem from "../BookmarkListItem"; 5 | import { loadMore } from "../../actions/bookmarks"; 6 | import { connect } from "react-redux"; 7 | import { Button, StyledList } from "./styles"; 8 | import { IBookmark, IAuth } from "../../types"; 9 | 10 | interface IProps { 11 | bookmarks: IBookmark[]; 12 | deleteBookmark: () => void; 13 | loadMore: (uid: string, lastBookmark: number) => void; 14 | searchTerm: string; 15 | tagFilter: string; 16 | auth: IAuth; 17 | lastBookmark: number; 18 | } 19 | 20 | const BookmarksList = ({ 21 | bookmarks, 22 | deleteBookmark, 23 | loadMore, 24 | searchTerm, 25 | tagFilter, 26 | auth, 27 | lastBookmark 28 | }: IProps) => { 29 | const handleLoadMore = e => { 30 | loadMore(auth.uid, lastBookmark); 31 | }; 32 | 33 | return ( 34 | 35 | {bookmarks.map(item => ( 36 | 41 | ))} 42 | {tagFilter === "default" && ( 43 |
44 | 45 |
46 | )} 47 |
48 | ); 49 | }; 50 | 51 | const mapStateToProps = state => { 52 | return { 53 | auth: state.auth, 54 | tagFilter: state.filters.tagFilter, 55 | searchTerm: state.filters.searchTerm, 56 | lastBookmark: state.lastBookmark, 57 | bookmarks: state.bookmarks 58 | }; 59 | }; 60 | 61 | export default connect( 62 | mapStateToProps, 63 | { loadMore } 64 | )(BookmarksList); 65 | -------------------------------------------------------------------------------- /src/components/BookmarksList/styles.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const Button = styled.button` 4 | background-color: #5755d9; 5 | border-radius: 3px; 6 | border: #5755d9; 7 | color: white; 8 | transition: 0.2s all; 9 | padding: 0.4rem; 10 | 11 | &:hover { 12 | border: 1px solid white; 13 | cursor: pointer; 14 | } 15 | &:active { 16 | background-color: hsla(191, 76%, 42%, 1); 17 | border-color: hsla(191, 76%, 32%, 1); 18 | box-shadow: inset 1px 1px 1px 0px rgba(0, 0, 0, 0.2); 19 | transform: translate(1px, 1px); 20 | } 21 | `; 22 | 23 | export const StyledList = styled.ul` 24 | margin: 0; 25 | padding: 0; 26 | .button-container { 27 | padding: 1rem; 28 | text-align: center; 29 | } 30 | /* margin-bottom: 2.4rem; */ 31 | background: #fff; 32 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.15); 33 | border-radius: 3px; 34 | min-height: 80vh; 35 | `; 36 | -------------------------------------------------------------------------------- /src/components/BookmarksPage/index.js: -------------------------------------------------------------------------------- 1 | //@flow 2 | 3 | import React, { useEffect } from "react"; 4 | import { connect } from "react-redux"; 5 | import BookmarksList from "../BookmarksList"; 6 | import LoadingSpinner from "../LoadingSpinner"; 7 | import { fetchBookmarks, deleteBookmark } from "../../actions/bookmarks"; 8 | import { loadingStart, loadingFinished } from "../../actions/loading"; 9 | import { StyledBookmarksPage } from "./styles"; 10 | import { IBookmark, IAuth } from "../../types"; 11 | 12 | interface IProps { 13 | bookmarks: IBookmark[]; 14 | fetchBookmarks: (uid: string) => void; 15 | deleteBookmark: () => void; 16 | auth: IAuth; 17 | isLoading: string; 18 | } 19 | 20 | const BookmarksPage = ({ 21 | fetchBookmarks, 22 | auth, 23 | isLoading, 24 | deleteBookmark 25 | }: IProps) => { 26 | useEffect(() => { 27 | fetchBookmarks(auth.uid); 28 | }, [auth.uid, fetchBookmarks]); 29 | 30 | return ( 31 | 32 | {isLoading && } 33 | 34 | 35 | ); 36 | }; 37 | 38 | const mapStateToProps = ({ auth, isLoading }) => { 39 | return { auth, isLoading }; 40 | }; 41 | 42 | export default connect( 43 | mapStateToProps, 44 | { fetchBookmarks, deleteBookmark, loadingStart, loadingFinished } 45 | )(BookmarksPage); 46 | -------------------------------------------------------------------------------- /src/components/BookmarksPage/styles.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const StyledBookmarksPage = styled.div` 4 | flex-basis: 80%; 5 | `; 6 | -------------------------------------------------------------------------------- /src/components/DebugComponent/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const StyledDebugComponent = styled.pre` 5 | code { 6 | background-color: none; 7 | } 8 | `; 9 | 10 | const DebugComponent = data => ( 11 | 12 | {JSON.stringify(data, null, 2)} 13 | 14 | ); 15 | 16 | export default DebugComponent; 17 | -------------------------------------------------------------------------------- /src/components/DrawerMenu/index.js: -------------------------------------------------------------------------------- 1 | //@flow 2 | 3 | import React from "react"; 4 | import { connect } from "react-redux"; 5 | import { queryByTag } from "../../actions/bookmarks"; 6 | import { setFilter } from "../../actions/filters"; 7 | import { signOut } from "../../actions/auth"; 8 | import { toggleDrawerMenu } from "../../actions/menuState"; 9 | import { Backdrop, StyledMenu, MenuHeader, FilterButton } from "./styles"; 10 | import { IAuth, ITag } from "../../types"; 11 | 12 | interface IProps { 13 | queryByTag: (uid: string, query: string) => void; 14 | setFilter: (query: string) => void; 15 | auth: IAuth; 16 | toggleDrawerMenu: () => void; 17 | tags: ITag[]; 18 | signOut: () => void; 19 | } 20 | 21 | const DrawerMenu = ({ 22 | tags, 23 | auth, 24 | setFilter, 25 | queryByTag, 26 | toggleDrawerMenu 27 | }: IProps) => { 28 | const handleTagQuery = (query: string) => { 29 | queryByTag(auth.uid, query); 30 | setFilter(query); 31 | toggleDrawerMenu(); 32 | }; 33 | 34 | return ( 35 | 36 | 37 | 38 | 41 |
42 | 43 |
44 |
45 | {tags.map(item => ( 46 | handleTagQuery(item.title)}> 47 | {item.title} 48 | 49 | ))} 50 |
51 |
52 | ); 53 | }; 54 | 55 | const mapStateToProps = ({ auth, tags }) => { 56 | return { auth, tags }; 57 | }; 58 | 59 | export default connect( 60 | mapStateToProps, 61 | { setFilter, queryByTag, toggleDrawerMenu, signOut } 62 | )(DrawerMenu); 63 | -------------------------------------------------------------------------------- /src/components/DrawerMenu/styles.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const Backdrop = styled.div` 4 | background-color: rgba(0, 0, 0, 0.3); 5 | position: fixed; 6 | width: 100%; 7 | height: 100%; 8 | top: 0; 9 | z-index: 100; 10 | `; 11 | 12 | export const StyledMenu = styled.div` 13 | background-color: white; 14 | height: 100%; 15 | width: 230px; 16 | overflow: scroll; 17 | `; 18 | 19 | export const MenuHeader = styled.header` 20 | display: flex; 21 | justify-content: space-between; 22 | padding: 0.5rem; 23 | `; 24 | 25 | export const FilterButton = styled.button` 26 | display: inline-block; 27 | width: 100%; 28 | border: none; 29 | padding: 0.7rem; 30 | margin: 0 0 3px 0; 31 | text-decoration: none; 32 | background: white; 33 | cursor: pointer; 34 | text-align: center; 35 | transition: background 250ms ease-in-out, transform 150ms ease; 36 | -webkit-appearance: none; 37 | -moz-appearance: none; 38 | 39 | &:hover { 40 | color: #1665d8; 41 | background-color: rgba(84, 147, 245, 0.1); 42 | } 43 | &:active { 44 | transform: scale(0.99); 45 | background-color: #1665d8; 46 | color: white; 47 | } 48 | `; 49 | -------------------------------------------------------------------------------- /src/components/EditBookmarkForm/index.js: -------------------------------------------------------------------------------- 1 | //@flow 2 | 3 | import React, { useEffect, useState } from "react"; 4 | import { RouteComponentProps } from "react-router-dom"; 5 | import { toast } from "react-toastify"; 6 | import { connect } from "react-redux"; 7 | import { fetchTags } from "../../actions/tags"; 8 | import { StyledForm } from "./styles"; 9 | import bookmarks from "../../api/bookmarks"; 10 | import { TextInput } from "../Inputs"; 11 | import { TagsInput } from "../Inputs/TagsInput"; 12 | import LoadingSpinner from "../LoadingSpinner"; 13 | import { IBookmark, ITag, IAuth } from "../../types"; 14 | 15 | interface IProps { 16 | bookmark: IBookmark; 17 | fetchTags: () => void; 18 | tagOptions: ITag[]; 19 | auth: IAuth; 20 | match: RouteComponentProps; 21 | history: RouteComponentProps; 22 | } 23 | 24 | const EditBookmarkForm = ({ match, history, auth }: IProps) => { 25 | const [bookmark, setBookmark] = useState(null); 26 | const [isLoading, setLoading] = useState(false); 27 | const [errors, setErrors] = useState([]); 28 | 29 | useEffect(() => { 30 | setLoading(true); 31 | const fetch = async () => { 32 | const response = await bookmarks.fetchBookmark(match.params.id, auth.uid); 33 | setBookmark(response); 34 | setLoading(false); 35 | }; 36 | fetch(); 37 | }, [auth.uid, match.params.id]); 38 | 39 | const handleChange = (event: any) => { 40 | const { name, value } = event.target; 41 | const updatedBookmark = { ...bookmark, [name]: value }; 42 | setBookmark(updatedBookmark); 43 | }; 44 | 45 | const handleCheck = event => { 46 | const updatedBookmark: any = { 47 | ...bookmark, 48 | pinned: event.target.checked 49 | }; 50 | setBookmark(updatedBookmark); 51 | }; 52 | 53 | const handleSubmit = event => { 54 | event.preventDefault(); 55 | let errors = []; 56 | if (bookmark && bookmark.title === "") { 57 | errors.push({ name: "title", msg: "cant be empty" }); 58 | } 59 | if (bookmark && bookmark.url === "") { 60 | errors.push({ name: "url", msg: "cant be empty" }); 61 | } 62 | setErrors(errors); 63 | const isValid = errors.length === 0; 64 | 65 | if (bookmark && isValid) { 66 | const { id, title, url, tag, userId, pinned } = bookmark; 67 | bookmarks.updateBookmark({ id, title, url, tag, userId, pinned }); 68 | history.push("/"); 69 | toast.success("updated bookmark"); 70 | } 71 | }; 72 | 73 | return ( 74 | <> 75 | {isLoading && } 76 | {bookmark && ( 77 | 78 |

Edit bookmark

79 | {errors.map(error => ( 80 |

81 | {error.name} {error.msg} 82 |

83 | ))} 84 | 91 | 98 | 106 |
107 | 116 |
117 |
118 | 121 |
122 |
123 | )} 124 | 125 | ); 126 | }; 127 | 128 | const mapStateToProps = (state, props) => { 129 | if (props.match.params.id) { 130 | return { 131 | auth: state.auth 132 | }; 133 | } 134 | return { bookmark: null }; 135 | }; 136 | 137 | export default connect( 138 | mapStateToProps, 139 | { fetchTags } 140 | )(EditBookmarkForm); 141 | -------------------------------------------------------------------------------- /src/components/EditBookmarkForm/styles.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const StyledForm = styled.form` 4 | padding: 1rem; 5 | .form-group.form-group-checkbox { 6 | margin: 20px 0 20px 0 !important; 7 | } 8 | padding: 1.5rem; 9 | max-width: 600px; 10 | background-color: white; 11 | border-radius: 3px; 12 | margin: 0 auto 0 auto; 13 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.15); 14 | .form-input, 15 | select { 16 | border-radius: 0px; 17 | padding-left: 0px; 18 | border-top: 0px; 19 | border-right: 0px; 20 | border-left: 0px; 21 | border-bottom: 1px solid blue; 22 | margin-bottom: 20px; 23 | } 24 | `; 25 | -------------------------------------------------------------------------------- /src/components/ExportFile/index.js: -------------------------------------------------------------------------------- 1 | //@flow 2 | 3 | import React from "react"; 4 | import styled from "styled-components"; 5 | import FileSaver from "file-saver"; 6 | import format from "date-fns/format"; 7 | import { connect } from "react-redux"; 8 | import { IBookmark } from "../../types"; 9 | 10 | const StyledButton = styled.button` 11 | font-size: 0.8em; 12 | `; 13 | 14 | type Props = { 15 | bookmarks: IBookmark[], 16 | }; 17 | 18 | class ExportFile extends React.Component { 19 | exportData = () => { 20 | const { bookmarks } = this.props; 21 | 22 | const exportedBookmarks = bookmarks.map((bookmark) => { 23 | return { title: bookmark.title, url: bookmark.url, tag: bookmark.tag }; 24 | }); 25 | 26 | const blob = new Blob([JSON.stringify(exportedBookmarks)], { 27 | type: "application/json;charset=utf-8", 28 | }); 29 | FileSaver.saveAs( 30 | blob, 31 | `better-bookmarks-export_${format(new Date(), "yyyy-MM-dd")}.json` 32 | ); 33 | }; 34 | render() { 35 | return ( 36 | 37 | Export to JSON 38 | 39 | ); 40 | } 41 | } 42 | 43 | const mapStateToProps = (state) => { 44 | return { 45 | bookmarks: state.bookmarks, 46 | }; 47 | }; 48 | 49 | export default connect(mapStateToProps)(ExportFile); 50 | -------------------------------------------------------------------------------- /src/components/ExportPage/index.js: -------------------------------------------------------------------------------- 1 | //@flow 2 | 3 | import React, { useEffect } from "react"; 4 | import { connect } from "react-redux"; 5 | import { fetchAllBookmarks } from "../../actions/bookmarks"; 6 | import ExportFile from "../ExportFile"; 7 | import LoadingSpinner from "../LoadingSpinner"; 8 | import { IBookmark, IAuth } from "../../types"; 9 | 10 | interface IProps { 11 | bookmarks: IBookmark[]; 12 | isLoading: boolean; 13 | fetchAllBookmarks: (uid: string) => void; 14 | auth: IAuth; 15 | } 16 | 17 | const ExportPage = ({ 18 | bookmarks, 19 | fetchAllBookmarks, 20 | auth, 21 | isLoading 22 | }: IProps) => { 23 | useEffect(() => { 24 | const fetch = () => { 25 | fetchAllBookmarks(auth.uid); 26 | }; 27 | fetch(); 28 | }, [auth.uid, fetchAllBookmarks]); 29 | 30 | return ( 31 |
32 |
    33 | 34 | {isLoading && } 35 | {bookmarks.map(item => ( 36 |
  • {item.title}
  • 37 | ))} 38 |
39 |
40 | ); 41 | }; 42 | 43 | const mapStateToProps = ({ auth, bookmarks, isLoading }) => { 44 | return { auth, bookmarks, isLoading }; 45 | }; 46 | 47 | export default connect( 48 | mapStateToProps, 49 | { fetchAllBookmarks } 50 | )(ExportPage); 51 | -------------------------------------------------------------------------------- /src/components/Icons/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const IconBookmarks = () => ( 4 | 10 | 11 | 12 | 13 | ); 14 | 15 | export const IconAdd = () => ( 16 | 22 | 23 | 24 | 25 | ); 26 | 27 | export const IconExport = () => ( 28 | 34 | 35 | 36 | 37 | ); 38 | 39 | export const IconTag = () => ( 40 | 46 | 47 | 48 | 49 | ); 50 | -------------------------------------------------------------------------------- /src/components/Inputs/TagsInput.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { useEffect, useState } from "react"; 4 | import tags from "../../api/tags"; 5 | 6 | export const TagsInput = ({ 7 | name, 8 | placeholder, 9 | label, 10 | value, 11 | handleChange, 12 | userID 13 | }: any) => { 14 | const [options, setOptions] = useState([]); 15 | 16 | useEffect(() => { 17 | const fetch = async () => { 18 | const response = await tags.fetchTags(userID); 19 | setOptions(response); 20 | }; 21 | fetch(); 22 | }, [userID]); 23 | 24 | return ( 25 |
26 | 27 | 39 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/Inputs/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from "react"; 4 | 5 | interface ITextInput { 6 | name: string; 7 | placeholder: string; 8 | label: string; 9 | value: string; 10 | handleChange: (event: any) => void; 11 | } 12 | 13 | export const TextInput = ({ 14 | name, 15 | placeholder, 16 | label, 17 | value, 18 | handleChange 19 | }: ITextInput) => ( 20 |
21 | 22 | handleChange(e)} 27 | type="text" 28 | id={name} 29 | placeholder="add title.." 30 | /> 31 |
32 | ); 33 | -------------------------------------------------------------------------------- /src/components/Loading/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const Spinner = styled.div` 5 | .sk-folding-cube { 6 | margin: 20px auto; 7 | width: 40px; 8 | height: 40px; 9 | position: relative; 10 | -webkit-transform: rotateZ(45deg); 11 | transform: rotateZ(45deg); 12 | } 13 | 14 | .sk-folding-cube .sk-cube { 15 | float: left; 16 | width: 50%; 17 | height: 50%; 18 | position: relative; 19 | -webkit-transform: scale(1.1); 20 | -ms-transform: scale(1.1); 21 | transform: scale(1.1); 22 | } 23 | .sk-folding-cube .sk-cube:before { 24 | content: ""; 25 | position: absolute; 26 | top: 0; 27 | left: 0; 28 | width: 100%; 29 | height: 100%; 30 | background-color: #9c27b0; 31 | -webkit-animation: sk-foldCubeAngle 2.4s infinite linear both; 32 | animation: sk-foldCubeAngle 2.4s infinite linear both; 33 | -webkit-transform-origin: 100% 100%; 34 | -ms-transform-origin: 100% 100%; 35 | transform-origin: 100% 100%; 36 | } 37 | .sk-folding-cube .sk-cube2 { 38 | -webkit-transform: scale(1.1) rotateZ(90deg); 39 | transform: scale(1.1) rotateZ(90deg); 40 | } 41 | .sk-folding-cube .sk-cube3 { 42 | -webkit-transform: scale(1.1) rotateZ(180deg); 43 | transform: scale(1.1) rotateZ(180deg); 44 | } 45 | .sk-folding-cube .sk-cube4 { 46 | -webkit-transform: scale(1.1) rotateZ(270deg); 47 | transform: scale(1.1) rotateZ(270deg); 48 | } 49 | .sk-folding-cube .sk-cube2:before { 50 | -webkit-animation-delay: 0.3s; 51 | animation-delay: 0.3s; 52 | } 53 | .sk-folding-cube .sk-cube3:before { 54 | -webkit-animation-delay: 0.6s; 55 | animation-delay: 0.6s; 56 | } 57 | .sk-folding-cube .sk-cube4:before { 58 | -webkit-animation-delay: 0.9s; 59 | animation-delay: 0.9s; 60 | } 61 | @-webkit-keyframes sk-foldCubeAngle { 62 | 0%, 63 | 10% { 64 | -webkit-transform: perspective(140px) rotateX(-180deg); 65 | transform: perspective(140px) rotateX(-180deg); 66 | opacity: 0; 67 | } 68 | 25%, 69 | 75% { 70 | -webkit-transform: perspective(140px) rotateX(0deg); 71 | transform: perspective(140px) rotateX(0deg); 72 | opacity: 1; 73 | } 74 | 90%, 75 | 100% { 76 | -webkit-transform: perspective(140px) rotateY(180deg); 77 | transform: perspective(140px) rotateY(180deg); 78 | opacity: 0; 79 | } 80 | } 81 | 82 | @keyframes sk-foldCubeAngle { 83 | 0%, 84 | 10% { 85 | -webkit-transform: perspective(140px) rotateX(-180deg); 86 | transform: perspective(140px) rotateX(-180deg); 87 | opacity: 0; 88 | } 89 | 25%, 90 | 75% { 91 | -webkit-transform: perspective(140px) rotateX(0deg); 92 | transform: perspective(140px) rotateX(0deg); 93 | opacity: 1; 94 | } 95 | 90%, 96 | 100% { 97 | -webkit-transform: perspective(140px) rotateY(180deg); 98 | transform: perspective(140px) rotateY(180deg); 99 | opacity: 0; 100 | } 101 | } 102 | `; 103 | 104 | const Loading = () => { 105 | return ( 106 | 107 |
108 |
109 |
110 |
111 |
112 |
113 | 114 | ); 115 | }; 116 | 117 | export default Loading; 118 | -------------------------------------------------------------------------------- /src/components/LoadingSpinner/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const StyledSpinner = styled.div` 5 | .lds-ring { 6 | width: 64px; 7 | height: 64px; 8 | margin: 40px auto; 9 | } 10 | .lds-ring div { 11 | box-sizing: border-box; 12 | display: block; 13 | position: absolute; 14 | width: 51px; 15 | height: 51px; 16 | margin: 6px; 17 | border: 6px solid #fff; 18 | border-radius: 50%; 19 | animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; 20 | border-color: blue transparent transparent transparent; 21 | } 22 | .lds-ring div:nth-child(1) { 23 | animation-delay: -0.45s; 24 | } 25 | .lds-ring div:nth-child(2) { 26 | animation-delay: -0.3s; 27 | } 28 | .lds-ring div:nth-child(3) { 29 | animation-delay: -0.15s; 30 | } 31 | @keyframes lds-ring { 32 | 0% { 33 | transform: rotate(0deg); 34 | } 35 | 100% { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | `; 40 | 41 | const LoadingSpinner = () => { 42 | return ( 43 | 44 |
45 |
46 |
47 |
48 |
49 |
50 | 51 | ); 52 | }; 53 | 54 | export default LoadingSpinner; 55 | -------------------------------------------------------------------------------- /src/components/MainHeader/index.js: -------------------------------------------------------------------------------- 1 | //@flow 2 | 3 | import React from "react"; 4 | import { connect } from "react-redux"; 5 | import { Link } from "react-router-dom"; 6 | import { signIn, signOut } from "../../actions/auth"; 7 | import { searchQuery } from "../../actions/bookmarks"; 8 | import { toggleDrawerMenu } from "../../actions/menuState"; 9 | import Search from "../SearchBar"; 10 | import { IAuth } from "../../types"; 11 | import { StyledHeader, HeaderNav, HeaderTop, StyledNavLink } from "./styles"; 12 | 13 | type Props = { 14 | auth: IAuth, 15 | signIn: () => void, 16 | signOut: () => void, 17 | toggleDrawerMenu: () => void 18 | }; 19 | 20 | const MainHeader = ({ toggleDrawerMenu }: Props) => ( 21 | 22 | 23 |
24 | 25 | Better
Bookmarks 26 | 27 |
28 | 34 |
35 | 36 |
37 |
38 | 39 | 40 | 41 | Bookmarks 42 | 43 | 44 | New bookmark 45 | 46 | 47 | tags 48 | 49 | 50 |
51 | ); 52 | 53 | const mapStateToProps = ({ auth, searchTerm }) => { 54 | return { 55 | auth: auth, 56 | searchTerm: searchTerm 57 | }; 58 | }; 59 | 60 | export default connect( 61 | mapStateToProps, 62 | { signIn, signOut, searchQuery, toggleDrawerMenu } 63 | )(MainHeader); 64 | -------------------------------------------------------------------------------- /src/components/MainHeader/styles.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { NavLink } from "react-router-dom"; 3 | 4 | export const StyledHeader = styled.header` 5 | background-color: #5755d9; 6 | `; 7 | 8 | export const HeaderTop = styled.div` 9 | background-color: #3f51b5; 10 | display: flex; 11 | padding: 10px; 12 | max-width: 1000px; 13 | margin: 0 auto; 14 | justify-content: space-between; 15 | align-items: center; 16 | .title-section { 17 | flex: 0 1 15%; 18 | a { 19 | color: white; 20 | font-weight: 700; 21 | } 22 | @media (max-width: 576px) { 23 | display: none; 24 | } 25 | } 26 | .search-controls { 27 | flex: 1 1 auto; 28 | .flex-container { 29 | display: flex; 30 | } 31 | input[type="search"] { 32 | flex: 1; 33 | border: 0px; 34 | outline: none; 35 | padding: 0.3rem; 36 | } 37 | input[type="submit"] { 38 | background-color: white; 39 | border: 1px solid white; 40 | border-left: 1px solid purple; 41 | box-shadow: 1px 1px 1px 0px rgba(0, 0, 0, 0.2); 42 | color: #5755d9; 43 | font-size: 0.7rem; 44 | } 45 | } 46 | .userinfo { 47 | text-align: center; 48 | flex: 0 1 10%; 49 | .btn.btn-link { 50 | color: #fff; 51 | &:hover { 52 | color: lightblue; 53 | } 54 | } 55 | } 56 | `; 57 | 58 | export const StyledNavLink = styled(NavLink)` 59 | padding: 0.6rem; 60 | display: block; 61 | color: lightgrey !important; 62 | &.active { 63 | color: white !important; 64 | font-weight: bold; 65 | border-bottom: 3px solid white; 66 | text-decoration: none; 67 | } 68 | &:active { 69 | outline: none; 70 | } 71 | `; 72 | 73 | export const HeaderNav = styled.nav` 74 | display: flex; 75 | justify-content: space-between; 76 | background-color: #5755d9; 77 | text-transform: uppercase; 78 | font-size: 0.7rem; 79 | align-items: center; 80 | @media (min-width: 576px) { 81 | display: none; 82 | } 83 | `; 84 | -------------------------------------------------------------------------------- /src/components/NewBookmarkForm/index.js: -------------------------------------------------------------------------------- 1 | //@flow 2 | 3 | import React, { Component } from "react"; 4 | import axios from "axios"; 5 | import { toast } from "react-toastify"; 6 | import { LAMBDA_ENDPOINT } from "../../config"; 7 | import { connect } from "react-redux"; 8 | import { createBookmark } from "../../actions/bookmarks"; 9 | import { fetchTags } from "../../actions/tags"; 10 | import { StyledForm } from "./styles"; 11 | import { IBookmark, ITag, IAuth } from "../../types"; 12 | 13 | type State = { 14 | title: string, 15 | url: string, 16 | tag: string, 17 | pinned: boolean, 18 | errors: {}, 19 | isLoading: boolean, 20 | params: null | {} 21 | }; 22 | 23 | type Props = { 24 | createBookmark: (bookmark: IBookmark) => void, 25 | createAlgoliaItem: () => void, 26 | tagOptions: ITag[], 27 | auth: IAuth, 28 | fetchTags: (uid: string) => void 29 | }; 30 | 31 | class NewBookmarkForm extends Component { 32 | state = { 33 | title: "", 34 | url: "", 35 | tag: "watch-later", 36 | pinned: false, 37 | errors: {}, 38 | isLoading: false, 39 | params: null 40 | }; 41 | 42 | componentDidMount() { 43 | this.props.fetchTags(this.props.auth.uid); 44 | this.getParams(); 45 | } 46 | 47 | handleChange = (event: SyntheticInputEvent) => { 48 | const value = 49 | event.target.type === "checkbox" 50 | ? event.target.checked 51 | : event.target.value; 52 | this.setState({ [event.target.name]: value }); 53 | if (event.target.name === "url") { 54 | this.getTitle(value); 55 | } 56 | }; 57 | 58 | validate = () => { 59 | const errors = {}; 60 | const { title, url, tag } = this.state; 61 | if (title === "") errors.title = "title is required"; 62 | if (url === "") errors.url = "url is required"; 63 | if (tag === "") errors.tag = "tag is required"; 64 | return Object.keys(errors).length === 0 ? null : errors; 65 | }; 66 | 67 | handleSubmit = event => { 68 | event.preventDefault(); 69 | const errors = this.validate(); 70 | this.setState({ errors: errors || {} }); 71 | if (errors) return; 72 | if (!errors) { 73 | const { title, url, tag, pinned } = this.state; 74 | const createdAt = Math.floor(Date.now() / 1000); 75 | const userId = this.props.auth.uid; 76 | const bookmark = { title, url, tag, pinned, createdAt, userId }; 77 | this.props.createBookmark(bookmark); 78 | this.setState({ title: "", url: "", tag: "" }); 79 | toast.success("Bookmark added!", { 80 | position: toast.POSITION.BOTTOM_CENTER 81 | }); 82 | } 83 | }; 84 | 85 | getTitle = async url => { 86 | this.setState({ isLoading: true }); 87 | const obj = { 88 | url: url 89 | }; 90 | try { 91 | let response = await axios.post( 92 | `${LAMBDA_ENDPOINT}get-title`, 93 | JSON.stringify(obj) 94 | ); 95 | this.setState({ title: response.data.pageTitle, isLoading: false }); 96 | } catch (error) { 97 | console.log("ERROR: " + error); 98 | this.setState({ 99 | isLoading: false 100 | }); 101 | } 102 | }; 103 | 104 | getParams = () => { 105 | const parsedUrl = new URL(window.location); 106 | const params: any = { 107 | text: parsedUrl.searchParams.get("text"), 108 | url: parsedUrl.searchParams.get("url"), 109 | title: parsedUrl.searchParams.get("title") 110 | }; 111 | this.setState({ params, url: params.text }); 112 | if (params.text && params.text.length > 0) { 113 | this.getTitle(params.text); 114 | } 115 | }; 116 | 117 | render() { 118 | return ( 119 | 120 | {this.state.isLoading && ( 121 |
122 | getting title.... 123 |
124 |
125 | )} 126 | 127 |
128 |

Add new Bookmark

129 |
130 | 131 | {this.state.errors.url && ( 132 |

{this.state.errors.url}

133 | )} 134 | 143 |
144 |
145 | 146 | {this.state.errors.title && ( 147 |

{this.state.errors.title}

148 | )} 149 | 158 |
159 | 160 |
161 | 162 | {this.state.errors.tag && ( 163 |

{this.state.errors.tag}

164 | )} 165 | 177 |
178 |
179 | 188 |
189 |
190 |
191 | 194 |
195 | 196 | ); 197 | } 198 | } 199 | 200 | const mapStateToProps = state => ({ 201 | tagOptions: state.tags, 202 | auth: state.auth 203 | }); 204 | 205 | export default connect( 206 | mapStateToProps, 207 | { fetchTags, createBookmark } 208 | )(NewBookmarkForm); 209 | -------------------------------------------------------------------------------- /src/components/NewBookmarkForm/styles.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const StyledForm = styled.form` 4 | padding: 1.5rem; 5 | max-width: 600px; 6 | margin: 0 auto 0 auto; 7 | background-color: white; 8 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.15); 9 | border-radius: 3px; 10 | .form-input, 11 | select { 12 | border-radius: 0px; 13 | padding-left: 0px; 14 | border-top: 0px; 15 | border-right: 0px; 16 | border-left: 0px; 17 | border-bottom: 1px solid blue; 18 | margin-bottom: 20px; 19 | } 20 | @media (min-width: 579px) { 21 | margin-top: 10px; 22 | } 23 | `; 24 | -------------------------------------------------------------------------------- /src/components/NewTagForm/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { connect } from "react-redux"; 3 | import { createTag } from "../../actions/tags"; 4 | import { StyledForm } from "./styles"; 5 | 6 | interface IProps { 7 | createTag: () => void; 8 | auth: any; 9 | } 10 | 11 | const NewTagForm = ({ auth, createTag }: IProps) => { 12 | const [title, setTitle] = useState(""); 13 | 14 | const handleChange = event => { 15 | setTitle(event.target.value); 16 | }; 17 | 18 | const handleSubmit = event => { 19 | event.preventDefault(); 20 | if (!title) { 21 | return; 22 | } 23 | const uid = auth.uid; 24 | createTag({ title, userId: uid }); 25 | setTitle(""); 26 | }; 27 | 28 | return ( 29 | 30 |

Add new tag

31 |
32 | 39 |
40 |
41 | 44 |
45 |
46 | ); 47 | }; 48 | 49 | const mapStateToProps = state => { 50 | return { 51 | auth: state.auth 52 | }; 53 | }; 54 | 55 | export default connect( 56 | mapStateToProps, 57 | { createTag } 58 | )(NewTagForm); 59 | -------------------------------------------------------------------------------- /src/components/NewTagForm/styles.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const StyledForm = styled.form` 4 | padding: 1.5rem; 5 | box-shadow: 0px 5px 5px lightgrey; 6 | margin: 0 auto 0 auto; 7 | background-color: white; 8 | border-radius: 3px; 9 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.15); 10 | .form-input, 11 | select { 12 | border-radius: 0px; 13 | padding-left: 0px; 14 | border-top: 0px; 15 | border-right: 0px; 16 | border-left: 0px; 17 | border-bottom: 1px solid blue; 18 | margin-bottom: 20px; 19 | } 20 | @media (min-width: 579px) { 21 | margin-top: 10px; 22 | } 23 | `; 24 | -------------------------------------------------------------------------------- /src/components/SearchBar/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | import { 4 | InstantSearch, 5 | Hits, 6 | SearchBox, 7 | Highlight, 8 | connectStateResults, 9 | } from "react-instantsearch-dom"; 10 | import { StyledSearch } from "./styles"; 11 | import { algoliaSearchClient } from "../../algolia"; 12 | 13 | const Bookmark = ({ hit }) => ( 14 | 15 | 16 | 17 | ); 18 | 19 | const Content = connectStateResults(({ searchState, searchResults }) => { 20 | const hasResults = searchResults && searchResults.nbHits !== 0; 21 | 22 | return ( 23 | 24 | {searchState.query && ( 25 | 28 | )} 29 | 30 | ); 31 | }); 32 | 33 | const Search = ({ uid }) => ( 34 | 35 | 39 | 40 | 41 | 42 | 43 | ); 44 | 45 | const mapStateToProps = ({ auth }) => { 46 | return { 47 | uid: auth.uid, 48 | }; 49 | }; 50 | 51 | export default connect(mapStateToProps)(Search); 52 | -------------------------------------------------------------------------------- /src/components/SearchBar/styles.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const StyledSearch = styled.div` 4 | padding: 1rem; 5 | .ais-Hits-list { 6 | min-width: 500px; 7 | max-width: 700px; 8 | border: 0px; 9 | position: absolute; 10 | display: block; 11 | margin: 0px 0px 0px 0px; 12 | box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); 13 | z-index: 1; 14 | @media (max-width: 450px) { 15 | left: 0; 16 | right: 0; 17 | } 18 | } 19 | .ais-Hits-item { 20 | border: 0px; 21 | margin: 0; 22 | width: initial; 23 | padding: 0.5rem; 24 | background-color: white; 25 | box-shadow: none; 26 | } 27 | [class^="ais-"] { 28 | font-size: 0.7rem; 29 | } 30 | .ais-Highlight-highlighted, 31 | .ais-Snippet-highlighted { 32 | background-color: #ddddf7; 33 | } 34 | .ais-SearchBox-submitIcon { 35 | display: none; 36 | } 37 | .ais-SearchBox-input { 38 | border-radius: 0px; 39 | } 40 | `; 41 | -------------------------------------------------------------------------------- /src/components/Sidebar/index.js: -------------------------------------------------------------------------------- 1 | //@flow 2 | 3 | import React, { useEffect, useState } from "react"; 4 | import { connect } from "react-redux"; 5 | import { fetchTags } from "../../actions/tags"; 6 | import { queryByTag, fetchBookmarks } from "../../actions/bookmarks"; 7 | import { setFilter } from "../../actions/filters"; 8 | import { Link } from "react-router-dom"; 9 | import { IconBookmarks, IconAdd, IconExport, IconTag } from "../Icons"; 10 | import { 11 | StyledSidebar, 12 | StyledActions, 13 | StyledListItem, 14 | StyledTagsList, 15 | SidebarButton 16 | } from "./styles"; 17 | 18 | interface IProps { 19 | auth: Object; 20 | fetchTags: (uid: string) => void; 21 | setFilter: (query: string) => void; 22 | tags: Array; 23 | filters: Object; 24 | queryByTag: (uid: string, query: string) => void; 25 | fetchBookmarks: (uid: string) => void; 26 | } 27 | 28 | const Sidebar = ({ 29 | auth, 30 | fetchTags, 31 | setFilter, 32 | tags, 33 | filters, 34 | queryByTag, 35 | fetchBookmarks 36 | }: IProps) => { 37 | const [filter, setTagFilter] = useState(""); 38 | 39 | useEffect(() => { 40 | fetchTags(auth.uid); 41 | }, [fetchTags, auth.uid]); 42 | 43 | const handleTagQuery = query => { 44 | queryByTag(auth.uid, query); 45 | setFilter(query); 46 | }; 47 | 48 | const getAllBookmarks = () => { 49 | fetchBookmarks(auth.uid); 50 | setFilter("default"); 51 | }; 52 | 53 | const filteredTags = tags.filter(tag => tag.title.includes(filter)); 54 | 55 | return ( 56 | 57 | 58 | 59 | 60 | All 61 | 62 | 63 | 64 | New 65 | 66 | 67 | 68 | Tags 69 | 70 | 71 | 72 | Export 73 | 74 | 75 | 76 | tags 77 |
78 | setTagFilter(event.target.value)} 84 | /> 85 |
86 |
  • 87 | 91 | All 92 | 93 |
  • 94 | {filteredTags.map(item => ( 95 | handleTagQuery(item.title)} 99 | isActive={filters.tagFilter === item.title ? true : false} 100 | > 101 | {item.title} 102 | 103 | ))} 104 |
    105 |
    106 | ); 107 | }; 108 | 109 | const mapStateToProps = ({ tags, auth, filters }) => { 110 | return { tags, auth, filters }; 111 | }; 112 | 113 | export default connect( 114 | mapStateToProps, 115 | { fetchTags, fetchBookmarks, setFilter, queryByTag } 116 | )(Sidebar); 117 | -------------------------------------------------------------------------------- /src/components/Sidebar/styles.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components"; 2 | 3 | export const StyledSidebar = styled.aside` 4 | background-color: #fafafa; 5 | flex: 1 1 15%; 6 | padding: 1rem; 7 | min-height: 100vh; 8 | ul { 9 | list-style-type: none; 10 | padding: 0; 11 | } 12 | @media (max-width: 576px) { 13 | display: none; 14 | } 15 | `; 16 | 17 | export const StyledActions = styled.ul` 18 | list-style-type: none; 19 | padding: 10px; 20 | margin: 0 0 20px 0; 21 | `; 22 | 23 | export const StyledTagsList = styled.ul` 24 | list-style-type: none; 25 | padding: 10px; 26 | margin: 0; 27 | button { 28 | margin-bottom: 2px; 29 | } 30 | `; 31 | 32 | export const SidebarButton = styled.button` 33 | background-color: #fafafa; 34 | width: 100%; 35 | border-radius: 3px; 36 | border: 0px; 37 | padding: 0.1rem 0.1rem 0.1rem 0.4rem; 38 | color: #5755d9; 39 | font-size: 0.7rem; 40 | text-align: left; 41 | transition: 0.2s all; 42 | &:hover { 43 | background-color: #1665d8; 44 | color: white; 45 | cursor: pointer; 46 | } 47 | &:active { 48 | background-color: hsla(191, 76%, 42%, 1); 49 | border-color: hsla(191, 76%, 32%, 1); 50 | box-shadow: inset 1px 1px 1px 0px rgba(0, 0, 0, 0.2); 51 | transform: translate(1px, 1px); 52 | } 53 | ${props => 54 | props.isActive && 55 | css` 56 | background: #5755d9; 57 | color: white; 58 | `}; 59 | `; 60 | 61 | export const StyledListItem = styled.li` 62 | display: flex; 63 | align-items: center; 64 | svg { 65 | margin-right: 10px; 66 | fill: #585ad6; 67 | } 68 | a { 69 | color: #585ad6; 70 | } 71 | `; 72 | -------------------------------------------------------------------------------- /src/components/SigninPage/index.js: -------------------------------------------------------------------------------- 1 | //@flow 2 | import React from "react"; 3 | 4 | interface IProps { 5 | signIn: () => void; 6 | } 7 | 8 | const SignIn = ({ signIn }: IProps) => { 9 | return ( 10 |
    11 |

    Better bookmarks

    12 |

    Signed Out

    13 |

    Please signin to start.

    14 |
    15 | 18 |
    19 |
    20 | ); 21 | }; 22 | 23 | export default SignIn; 24 | -------------------------------------------------------------------------------- /src/components/TagsPage/index.js: -------------------------------------------------------------------------------- 1 | //@flow 2 | 3 | import React from "react"; 4 | import { connect } from "react-redux"; 5 | import NewTagForm from "../NewTagForm"; 6 | import { Link } from "react-router-dom"; 7 | import { fetchTags, deleteTag } from "../../actions/tags"; 8 | import { setFilter } from "../../actions/filters"; 9 | import styled from "styled-components"; 10 | 11 | const StyledTagList = styled.div` 12 | padding: 20px; 13 | max-width: 650px; 14 | margin: 0 auto; 15 | table { 16 | margin-top: 20px; 17 | box-shadow: 0px 5px 5px lightgrey; 18 | } 19 | @media (max-width: 450px) { 20 | padding: 0px; 21 | } 22 | `; 23 | 24 | type Props = { 25 | tags: Array, 26 | fetchTags: Function, 27 | deleteTag: Function, 28 | setFilter: Function, 29 | auth: Object 30 | }; 31 | 32 | const TagsPage = ({ tags, setFilter, deleteTag, auth }: Props) => { 33 | return ( 34 | 35 | 36 | 37 | 38 | 39 | 40 | 42 | 43 | 44 | {tags.map(tag => ( 45 | 46 | 51 | 52 | 60 | 61 | ))} 62 | 63 |
    all tags 41 |
    47 | setFilter(tag.title)}> 48 | {tag.title} 49 | 50 | 53 | 59 |
    64 |
    65 | ); 66 | }; 67 | 68 | const mapStateToProps = ({ tags, auth }) => { 69 | return { tags, auth }; 70 | }; 71 | 72 | export default connect( 73 | mapStateToProps, 74 | { fetchTags, deleteTag, setFilter } 75 | )(TagsPage); 76 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | export const LAMBDA_ENDPOINT = process.env.REACT_APP_LAMBDA_ENDPOINT; 2 | -------------------------------------------------------------------------------- /src/firebase.js: -------------------------------------------------------------------------------- 1 | import firebase from "firebase/app"; 2 | import "firebase/firestore"; 3 | import "firebase/auth"; 4 | 5 | // CLIENT SIDE KEYS 6 | 7 | const config = { 8 | apiKey: process.env.REACT_APP_API_KEY, 9 | authDomain: process.env.REACT_APP_DOMAIN, 10 | databaseURL: process.env.REACT_APP_DATABASE, 11 | projectId: process.env.REACT_APP_PROJECT_ID, 12 | storageBucket: process.env.REACT_APP_BUCKET, 13 | messagingSenderId: process.env.REACT_APP_MESSAGE_ID 14 | }; 15 | firebase.initializeApp(config); 16 | 17 | export const firestore = firebase.firestore(); 18 | // const settings = { timestampsInSnapshots: true }; 19 | // firestore.settings(settings); 20 | export const auth = firebase.auth(); 21 | export const googleAuthProvider = new firebase.auth.GoogleAuthProvider(); 22 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { Provider } from "react-redux"; 4 | import "spectre.css/dist/spectre.min.css"; 5 | import "spectre.css/dist/spectre-icons.css"; 6 | import Application from "./components/Application"; 7 | import { startListeningToAuthChanges } from "./actions/auth"; 8 | import * as serviceWorker from "./serviceWorker"; 9 | import { store } from "./store"; 10 | 11 | store.dispatch(startListeningToAuthChanges()); 12 | 13 | ReactDOM.render( 14 | 15 | 16 | , 17 | document.getElementById("root") 18 | ); 19 | 20 | serviceWorker.register(); 21 | -------------------------------------------------------------------------------- /src/initial-state.js: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | auth: { 3 | status: "ANONYMOUS", 4 | email: null, 5 | displayName: null, 6 | photoURL: null, 7 | uid: null 8 | }, 9 | filters: { 10 | tagFilter: "default", 11 | searchTerm: "" 12 | }, 13 | isLoading: false, 14 | tags: [], 15 | bookmarks: [], 16 | lastBookmark: null, 17 | menuIsOpen: false 18 | }; 19 | 20 | export default initialState; 21 | -------------------------------------------------------------------------------- /src/lambda/algolia-add.js: -------------------------------------------------------------------------------- 1 | var algoliasearch = require("algoliasearch"); 2 | const dotenv = require("dotenv"); 3 | 4 | dotenv.config(); 5 | 6 | var client = algoliasearch( 7 | process.env.REACT_APP_ALGOLIA_APP_ID, 8 | process.env.ALGOLIA_API_KEY 9 | ); 10 | 11 | exports.handler = async (event, context) => { 12 | if (event.httpMethod !== "POST") { 13 | return { statusCode: 405, body: "Method Not Allowed" }; 14 | } 15 | 16 | try { 17 | const bookmark = JSON.parse(event.body); 18 | var index = client.initIndex(`bookmarks-${bookmark.userId}`); 19 | const bookmarkWithObjectID = { ...bookmark, objectID: bookmark.id }; 20 | await index.saveObjects([bookmarkWithObjectID]); 21 | return { 22 | headers: { 23 | "content-type": "application/json", 24 | "Access-Control-Allow-Origin": "*", 25 | }, 26 | statusCode: 200, 27 | body: `Added new bookmark to index`, 28 | }; 29 | } catch (error) { 30 | return { statusCode: 500, body: error.toString() }; 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /src/lambda/algolia-delete.js: -------------------------------------------------------------------------------- 1 | var algoliasearch = require("algoliasearch"); 2 | const dotenv = require("dotenv"); 3 | 4 | dotenv.config(); 5 | 6 | var client = algoliasearch( 7 | process.env.REACT_APP_ALGOLIA_APP_ID, 8 | process.env.ALGOLIA_API_KEY 9 | ); 10 | 11 | exports.handler = async (event, context) => { 12 | if (event.httpMethod !== "POST") { 13 | return { statusCode: 405, body: "Method Not Allowed" }; 14 | } 15 | 16 | try { 17 | const bookmark = JSON.parse(event.body); 18 | var index = client.initIndex(`bookmarks-${bookmark.userId}`); 19 | const result = await index.search(bookmark.id); 20 | const objectID = result.hits[0].objectID; 21 | await index.deleteObject(objectID); 22 | 23 | return { 24 | headers: { 25 | "content-type": "application/json", 26 | "Access-Control-Allow-Origin": "*", 27 | }, 28 | statusCode: 200, 29 | body: `Deleted item from index`, 30 | }; 31 | } catch (error) { 32 | return { statusCode: 500, body: error.toString() }; 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /src/lambda/get-title.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | const cheerio = require("cheerio"); 3 | 4 | exports.handler = async (event, context) => { 5 | try { 6 | const data = JSON.parse(event.body); 7 | const response = await axios.get(data.url); 8 | const $ = cheerio.load(response.data); 9 | const pageTitle = $("title").text(); 10 | 11 | return { 12 | headers: { 13 | "content-type": "application/json", 14 | "Access-Control-Allow-Origin": "*" 15 | }, 16 | statusCode: 200, 17 | body: JSON.stringify({ pageTitle }) 18 | }; 19 | } catch (error) { 20 | return { 21 | statusCode: 500, 22 | body: error.toString() 23 | }; 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /src/reducers/auth.js: -------------------------------------------------------------------------------- 1 | import initialState from '../initial-state.js'; 2 | 3 | export default function authReducer(state = initialState.auth, action) { 4 | switch(action.type) { 5 | case 'ATTEMPTING_LOGIN': 6 | return { 7 | status: 'AWAITING_AUTH_RESPONSE' 8 | }; 9 | case 'SIGN_OUT': 10 | return { 11 | status: 'ANONYMOUS', 12 | email: null, 13 | displayName: null, 14 | photoURL: null, 15 | uid: null 16 | }; 17 | case 'SIGN_IN': 18 | return { 19 | status: 'SIGNED_IN', 20 | email: action.email, 21 | displayName: action.displayName, 22 | photoURL: action.photoURL, 23 | uid: action.uid 24 | }; 25 | default: 26 | return state; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/reducers/bookmarks.js: -------------------------------------------------------------------------------- 1 | import initialState from "../initial-state.js"; 2 | 3 | const addBookmark = (state, action) => { 4 | const { id, url, userId, title, createdAt, tags } = action.payload; 5 | return [ 6 | ...state, 7 | { 8 | id, 9 | url, 10 | userId, 11 | title, 12 | createdAt, 13 | tags 14 | } 15 | ]; 16 | }; 17 | 18 | const removeBookmark = (state, action) => { 19 | return [...state].filter(item => item.id !== action.payload.id); 20 | }; 21 | 22 | const setBookmarks = (state, action) => { 23 | return action.payload; 24 | }; 25 | 26 | const paginateBookmarks = (state, action) => { 27 | return [...state, ...action.payload]; 28 | }; 29 | 30 | const setBookmark = (state, action) => { 31 | // refactor? 32 | const index = state.findIndex(item => item.id === action.payload.id); 33 | if (index > -1) { 34 | return state.map(item => { 35 | if (item.id === action.payload.id) return action.payload; 36 | return item; 37 | }); 38 | } else { 39 | return [...state, action.payload]; 40 | } 41 | }; 42 | 43 | // set to initialState 44 | export default (state = initialState, action) => { 45 | switch (action.type) { 46 | case "SET_BOOKMARKS": 47 | return setBookmarks(state, action); 48 | case "PAGINATE_BOOKMARKS": 49 | return paginateBookmarks(state, action); 50 | case "ADD_BOOKMARK": 51 | return addBookmark(state, action); 52 | case "REMOVE_BOOKMARK": 53 | return removeBookmark(state, action); 54 | case "BOOKMARK_FETCHED": 55 | return setBookmark(state, action); 56 | default: 57 | return state; 58 | } 59 | }; 60 | -------------------------------------------------------------------------------- /src/reducers/filters.js: -------------------------------------------------------------------------------- 1 | import initialState from "../initial-state"; 2 | 3 | export default function filterReducer(state = initialState, action) { 4 | switch (action.type) { 5 | case "SET_FILTER": 6 | return { ...state, tagFilter: action.tag }; 7 | case "SET_SEARCH_TERM": 8 | return { ...state, searchTerm: action.searchTerm }; 9 | default: 10 | return state; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | import authReducer from "./auth"; 3 | import bookmarksReducer from "./bookmarks"; 4 | import tagsReducer from "./tags"; 5 | import filterReducer from "./filters"; 6 | import loadingStateReducer from "./loading"; 7 | import paginationReducer from "./pagination"; 8 | import menuStateReducer from "./menuState"; 9 | 10 | const reducer = combineReducers({ 11 | tags: tagsReducer, 12 | auth: authReducer, 13 | filters: filterReducer, 14 | isLoading: loadingStateReducer, 15 | bookmarks: bookmarksReducer, 16 | lastBookmark: paginationReducer, 17 | menuIsOpen: menuStateReducer 18 | }); 19 | 20 | export default reducer; 21 | -------------------------------------------------------------------------------- /src/reducers/loading.js: -------------------------------------------------------------------------------- 1 | import initialState from "../initial-state"; 2 | 3 | export default function loadingStateReducer(state = initialState, action) { 4 | switch (action.type) { 5 | case "LOADING_START": 6 | return true; 7 | case "LOADING_FINISHED": 8 | return false; 9 | default: 10 | return state; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/reducers/menuState.js: -------------------------------------------------------------------------------- 1 | import initialState from "../initial-state"; 2 | 3 | export default function menuStateReducer(state = initialState, action) { 4 | if (action.type === "TOGGLE_DRAWER_MENU") { 5 | return !state; 6 | } 7 | return state; 8 | } 9 | -------------------------------------------------------------------------------- /src/reducers/pagination.js: -------------------------------------------------------------------------------- 1 | import initialState from "../initial-state"; 2 | 3 | export default function paginationReducer(state = initialState, action) { 4 | if (action.type === "SET_LAST_BOOKMARK") { 5 | return action.lastbookmark.createdAt; 6 | } 7 | return state; 8 | } 9 | -------------------------------------------------------------------------------- /src/reducers/tags.js: -------------------------------------------------------------------------------- 1 | import initialState from "../initial-state"; 2 | 3 | const setTags = (state, action) => { 4 | return action.tags; 5 | }; 6 | 7 | const removeTag = (state, action) => { 8 | return [...state].filter(item => item.id !== action.id); 9 | }; 10 | 11 | export default function tagsReducer(state = initialState.tags, action) { 12 | switch (action.type) { 13 | case "ADD_TAG": 14 | return [...state, action.tag]; 15 | case "SET_TAGS": 16 | return setTags(state, action); 17 | case "REMOVE_TAG": 18 | return removeTag(state, action); 19 | default: 20 | return state; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === "localhost" || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === "[::1]" || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener("load", () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | "This web app is being served cache-first by a service " + 46 | "worker. To learn more, visit https://bit.ly/CRA-PWA" 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === "installed") { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | "New content is available and will be used when all " + 74 | "tabs for this page are closed. See https://bit.ly/CRA-PWA." 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log("Content is cached for offline use."); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error("Error during service worker registration:", error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get("content-type"); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf("javascript") === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | "No internet connection found. App is running in offline mode." 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ("serviceWorker" in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, compose, createStore } from "redux"; 2 | import thunk from "redux-thunk"; 3 | import reducer from "./reducers"; 4 | import initialState from "./initial-state"; 5 | 6 | const middleware = [thunk]; 7 | const enhancers = []; 8 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 9 | 10 | export const store = createStore( 11 | reducer, 12 | initialState, 13 | composeEnhancers(applyMiddleware(...middleware), ...enhancers) 14 | ); 15 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | //@flow 2 | 3 | export interface IBookmark { 4 | id: string; 5 | createdAt?: number; 6 | pinned: boolean; 7 | tag: string; 8 | title: string; 9 | url: string; 10 | userId: string; 11 | } 12 | 13 | export interface IAuth { 14 | status: string; 15 | email: string | null; 16 | displayName: string | null; 17 | photoURL: string | null; 18 | uid: string; 19 | } 20 | 21 | export interface ITag { 22 | id: string; 23 | title: string; 24 | userId: string; 25 | } 26 | --------------------------------------------------------------------------------