├── .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 | 
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 |
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 |
13 | );
14 |
15 | export const IconAdd = () => (
16 |
25 | );
26 |
27 | export const IconExport = () => (
28 |
37 | );
38 |
39 | export const IconTag = () => (
40 |
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 |
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 |
26 |
27 |
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