├── dev
├── .gitignore
├── vault
│ ├── bin
│ │ ├── seal.sh
│ │ ├── read.sh
│ │ ├── unseal.sh
│ │ └── copy.sh
│ ├── .gitignore
│ ├── Dockerfile
│ └── vault.config.json
└── workspace
│ ├── .gitignore
│ ├── env.list.dist
│ ├── .zshrc
│ └── Dockerfile
├── app
├── .node-version
├── functions
│ ├── .node-version
│ ├── http
│ │ ├── index.js
│ │ ├── image.onRequest.spec.js
│ │ └── image.onRequest.js
│ ├── firestore
│ │ ├── index.js
│ │ ├── algolia.onWrite.js
│ │ └── algolia.onWrite.spec.js
│ ├── storage
│ │ ├── index.js
│ │ ├── uploads.onChange.spec.js
│ │ └── uploads.onChange.js
│ ├── auth
│ │ ├── index.js
│ │ ├── customClaim.onCreate.js
│ │ └── customClaim.onCreate.spec.js
│ ├── utils
│ │ ├── collections.util.js
│ │ ├── index.js
│ │ ├── admin.util.js
│ │ ├── algolia.util.js
│ │ ├── environment.util.spec.js
│ │ └── environment.util.js
│ ├── config.json.dist
│ ├── index.js
│ └── package.json
├── src
│ ├── utils
│ │ ├── index.js
│ │ └── batch.util.js
│ ├── favicon.ico
│ ├── assets
│ │ ├── icon.png
│ │ ├── favicon.ico
│ │ ├── logo-built_white.png
│ │ ├── svg
│ │ │ ├── add.svg
│ │ │ ├── menu.svg
│ │ │ ├── skip-next.svg
│ │ │ ├── view-carousel.svg
│ │ │ ├── delete.svg
│ │ │ ├── clear.svg
│ │ │ ├── open-with.svg
│ │ │ ├── check-circle.svg
│ │ │ ├── content-copy.svg
│ │ │ ├── search.svg
│ │ │ ├── share.svg
│ │ │ ├── three-dots.svg
│ │ │ └── spinner.svg
│ │ └── algolia.svg
│ ├── observers
│ │ ├── index.js
│ │ ├── images.observer.js
│ │ └── search.observer.js
│ ├── datastore
│ │ ├── actions
│ │ │ ├── setImages.action.js
│ │ │ ├── setSearch.action.js
│ │ │ ├── clearSelection.action.js
│ │ │ ├── toggleMenu.action.js
│ │ │ ├── setImagesWidth.action.js
│ │ │ ├── setToken.action.js
│ │ │ ├── setAllImagesLoaded.action.js
│ │ │ ├── setPath.action.js
│ │ │ ├── setImagesAllLoaded.action.js
│ │ │ ├── setSelecting.action.js
│ │ │ ├── setCurrentUser.action.js
│ │ │ ├── setImage.action.js
│ │ │ ├── setSearchResults.action.js
│ │ │ ├── addImage.action.js
│ │ │ ├── removeSelection.action.js
│ │ │ ├── addSelection.action.js
│ │ │ ├── addImages.action.js
│ │ │ ├── setControlSelect.action.js
│ │ │ ├── setShiftSelect.action.js
│ │ │ ├── setSearching.action.js
│ │ │ ├── updateImage.action.js
│ │ │ ├── addImageVersion.action.js
│ │ │ ├── index.js
│ │ │ └── deleteSelection.action.js
│ │ └── index.js
│ ├── components
│ │ ├── views
│ │ │ ├── index.js
│ │ │ ├── tags.scss
│ │ │ ├── tags.view.scss
│ │ │ ├── embed.view.scss
│ │ │ ├── images.view.scss
│ │ │ ├── galleries.view.scss
│ │ │ ├── tags.view.js
│ │ │ ├── embed.view.js
│ │ │ ├── galleries.view.js
│ │ │ ├── images.view.js
│ │ │ ├── gallery.view.scss
│ │ │ └── gallery.view.js
│ │ ├── drawer
│ │ │ ├── drawer.scss
│ │ │ └── drawer.component.js
│ │ ├── nav
│ │ │ ├── nav.scss
│ │ │ └── nav.component.js
│ │ ├── guard
│ │ │ └── guard.component.js
│ │ ├── search
│ │ │ ├── search.scss
│ │ │ └── search.component.js
│ │ ├── tags
│ │ │ ├── tags.scss
│ │ │ └── tags.component.js
│ │ ├── image-detail
│ │ │ ├── imageDetail.scss
│ │ │ └── imageDetail.component.js
│ │ └── images
│ │ │ ├── images.scss
│ │ │ └── images.component.js
│ ├── queries
│ │ ├── index.js
│ │ ├── deleteImage.query.js
│ │ ├── image.query.js
│ │ ├── updateImage.query.js
│ │ ├── imagesByTag.query.js
│ │ ├── updateTags.query.js
│ │ ├── images.query.js
│ │ └── imageVersion.query.js
│ ├── manifest.json
│ ├── style.css
│ ├── environment.js.dist
│ └── index.js
├── data
│ ├── jpg.jpg
│ ├── image-book.lnk
│ ├── test-MjQyNjY5M2NiNTYxM2M4MTkwZmY0YjNmZDdjM2E3NzI=.json
│ └── sample-storage-event.json
├── favicon.ico
├── assets
│ ├── icon.png
│ └── favicon.ico
├── tsconfig.json
├── database.rules.bolt
├── .gitignore
├── storage.rules
├── firestore.indexes.json
├── firestore.rules
├── firebase.json
├── preact.config.js
├── LICENSE
├── package.json
├── template.html
└── README.md
├── .gitattributes
├── bin
├── vault
│ ├── encode-base-64.sh
│ ├── decode-base-64.sh
│ ├── interactive-vault.sh
│ ├── run-vault.sh
│ ├── copy-vault-keys.ps1
│ ├── copy-vault-keys.sh
│ └── expand-secrets.js
├── cloudbuild-submit.ps1
├── watch.ps1
└── docker-login.ps1
├── .dockerignore
├── .vscode
└── settings.json
├── Dockerfile
├── .devcontainer.json
├── cloudbuild.yaml
├── functions
└── utils
│ └── environment.util.spec.js
├── docker-compose.yaml
├── package.json
├── preact.config.js
└── README.md
/dev/.gitignore:
--------------------------------------------------------------------------------
1 | env.list
--------------------------------------------------------------------------------
/app/.node-version:
--------------------------------------------------------------------------------
1 | lts
2 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | core.autocrlf false
--------------------------------------------------------------------------------
/app/functions/.node-version:
--------------------------------------------------------------------------------
1 | 8.15.1
2 |
--------------------------------------------------------------------------------
/bin/vault/encode-base-64.sh:
--------------------------------------------------------------------------------
1 | cat $1 | base64
--------------------------------------------------------------------------------
/dev/vault/bin/seal.sh:
--------------------------------------------------------------------------------
1 | vault operator seal
--------------------------------------------------------------------------------
/dev/workspace/.gitignore:
--------------------------------------------------------------------------------
1 | service-account.json
--------------------------------------------------------------------------------
/bin/vault/decode-base-64.sh:
--------------------------------------------------------------------------------
1 | echo $1 | base64 --decode
--------------------------------------------------------------------------------
/dev/workspace/env.list.dist:
--------------------------------------------------------------------------------
1 | FIREBASE_TOKEN=ABCDEFG
--------------------------------------------------------------------------------
/app/src/utils/index.js:
--------------------------------------------------------------------------------
1 | export * from './batch.util';
2 |
--------------------------------------------------------------------------------
/dev/vault/.gitignore:
--------------------------------------------------------------------------------
1 | env.list
2 | service-account.json
3 |
--------------------------------------------------------------------------------
/dev/vault/bin/read.sh:
--------------------------------------------------------------------------------
1 | vault read $VAULT_PATH -format="json"
--------------------------------------------------------------------------------
/dev/vault/bin/unseal.sh:
--------------------------------------------------------------------------------
1 | vault operator unseal $VAULT_KEY_1
--------------------------------------------------------------------------------
/bin/vault/interactive-vault.sh:
--------------------------------------------------------------------------------
1 | docker-compose run --service-ports vault sh
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .vscode
2 | bin
3 | dev
4 |
5 | app/dist
6 | app/node_modules
--------------------------------------------------------------------------------
/app/data/jpg.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/how-to-firebase/fogo/HEAD/app/data/jpg.jpg
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/how-to-firebase/fogo/HEAD/app/favicon.ico
--------------------------------------------------------------------------------
/dev/vault/bin/copy.sh:
--------------------------------------------------------------------------------
1 | vault read $VAULT_PATH -format="json" > /app/vault/secrets.json
--------------------------------------------------------------------------------
/app/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/how-to-firebase/fogo/HEAD/app/assets/icon.png
--------------------------------------------------------------------------------
/app/src/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/how-to-firebase/fogo/HEAD/app/src/favicon.ico
--------------------------------------------------------------------------------
/app/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/how-to-firebase/fogo/HEAD/app/assets/favicon.ico
--------------------------------------------------------------------------------
/app/data/image-book.lnk:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/how-to-firebase/fogo/HEAD/app/data/image-book.lnk
--------------------------------------------------------------------------------
/app/src/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/how-to-firebase/fogo/HEAD/app/src/assets/icon.png
--------------------------------------------------------------------------------
/bin/cloudbuild-submit.ps1:
--------------------------------------------------------------------------------
1 | gcloud builds submit --tag gcr.io/fir-consulting-2019/firebase-consulting .
--------------------------------------------------------------------------------
/app/src/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/how-to-firebase/fogo/HEAD/app/src/assets/favicon.ico
--------------------------------------------------------------------------------
/app/src/observers/index.js:
--------------------------------------------------------------------------------
1 | export * from './images.observer';
2 | export * from './search.observer';
3 |
--------------------------------------------------------------------------------
/bin/vault/run-vault.sh:
--------------------------------------------------------------------------------
1 | docker-compose run --service-ports vault server -config /dev/vault/vault.config.json
--------------------------------------------------------------------------------
/app/functions/http/index.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | imageOnRequest: require('./image.onRequest'),
3 | };
4 |
--------------------------------------------------------------------------------
/app/functions/firestore/index.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | algoliaOnWrite: require('./algolia.onWrite'),
3 | };
4 |
--------------------------------------------------------------------------------
/app/functions/storage/index.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | uploadsOnChange: require('./uploads.onChange'),
3 | };
4 |
--------------------------------------------------------------------------------
/app/functions/auth/index.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | customClaimOnCreate: require('./customClaim.onCreate'),
3 | };
4 |
--------------------------------------------------------------------------------
/app/src/assets/logo-built_white.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/how-to-firebase/fogo/HEAD/app/src/assets/logo-built_white.png
--------------------------------------------------------------------------------
/app/src/datastore/actions/setImages.action.js:
--------------------------------------------------------------------------------
1 | export function setImages(state, images) {
2 | return { images };
3 | }
4 |
--------------------------------------------------------------------------------
/app/src/datastore/actions/setSearch.action.js:
--------------------------------------------------------------------------------
1 | export function setSearch(state, search = '') {
2 | return { search };
3 | }
4 |
--------------------------------------------------------------------------------
/app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "experimentalDecorators": true,
4 | "allowJs": true
5 | }
6 | }
--------------------------------------------------------------------------------
/app/src/datastore/actions/clearSelection.action.js:
--------------------------------------------------------------------------------
1 | export function clearSelection() {
2 | return { selection: new Set() };
3 | }
4 |
--------------------------------------------------------------------------------
/bin/watch.ps1:
--------------------------------------------------------------------------------
1 | invoke-expression 'cmd /c start powershell -windowstyle minimized -Command { docker-volume-watcher -e "*node_modules*" }'
--------------------------------------------------------------------------------
/app/src/datastore/actions/toggleMenu.action.js:
--------------------------------------------------------------------------------
1 | export function toggleMenu({ showMenu }) {
2 | return { showMenu: !showMenu };
3 | }
4 |
--------------------------------------------------------------------------------
/app/src/datastore/actions/setImagesWidth.action.js:
--------------------------------------------------------------------------------
1 | export function setImagesWidth(state, imagesWidth) {
2 | return { imagesWidth };
3 | }
4 |
--------------------------------------------------------------------------------
/app/src/datastore/actions/setToken.action.js:
--------------------------------------------------------------------------------
1 | export function setToken(state, token) {
2 | return { token, isAdmin: token.admin };
3 | }
4 |
--------------------------------------------------------------------------------
/app/src/datastore/actions/setAllImagesLoaded.action.js:
--------------------------------------------------------------------------------
1 | export function setImages(state, imagesAllLoaded) {
2 | return { imagesAllLoaded };
3 | }
4 |
--------------------------------------------------------------------------------
/app/src/datastore/actions/setPath.action.js:
--------------------------------------------------------------------------------
1 | export function setPath({ path: laggedPath, showNav }, path) {
2 | return { laggedPath, path };
3 | }
4 |
--------------------------------------------------------------------------------
/app/src/datastore/actions/setImagesAllLoaded.action.js:
--------------------------------------------------------------------------------
1 | export function setImagesAllLoaded(state, imagesAllLoaded) {
2 | return { imagesAllLoaded };
3 | }
4 |
--------------------------------------------------------------------------------
/app/database.rules.bolt:
--------------------------------------------------------------------------------
1 | path /notifications/{uid} {
2 | read() { isCurrentUser(uid) }
3 | }
4 |
5 | isCurrentUser(uid) {
6 | auth != null && auth.uid == uid
7 | }
8 |
--------------------------------------------------------------------------------
/app/src/datastore/actions/setSelecting.action.js:
--------------------------------------------------------------------------------
1 | export function setSelecting({ searching }, selecting) {
2 | return { selecting: !!selecting || searching };
3 | }
4 |
--------------------------------------------------------------------------------
/app/src/datastore/actions/setCurrentUser.action.js:
--------------------------------------------------------------------------------
1 | export function setCurrentUser({ currentUser: laggedCurrentUser }, currentUser) {
2 | return { laggedCurrentUser, currentUser };
3 | }
4 |
--------------------------------------------------------------------------------
/app/src/datastore/actions/setImage.action.js:
--------------------------------------------------------------------------------
1 | export function setImage(state, { ...image }) {
2 | if (!Object.keys(image).length) {
3 | image = null;
4 | }
5 | return { image };
6 | }
7 |
--------------------------------------------------------------------------------
/bin/docker-login.ps1:
--------------------------------------------------------------------------------
1 | # See https://cloud.google.com/container-registry/docs/advanced-authentication#gcloud_docker
2 | # gcloud components install docker-credential-gcr
3 | docker-credential-gcr configure-docker
--------------------------------------------------------------------------------
/app/src/components/views/index.js:
--------------------------------------------------------------------------------
1 | export * from './embed.view';
2 | export * from './galleries.view';
3 | export * from './gallery.view';
4 | export * from './images.view';
5 | export * from './tags.view';
6 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "workbench.colorCustomizations": {
3 | "activityBar.background": "#122F4E",
4 | "titleBar.activeBackground": "#19416E",
5 | "titleBar.activeForeground": "#F7FAFD"
6 | }
7 | }
--------------------------------------------------------------------------------
/app/functions/utils/collections.util.js:
--------------------------------------------------------------------------------
1 | module.exports = environment => {
2 | return new Map(Object.keys(environment.collections).map(key => [
3 | key,
4 | environment.collections[key],
5 | ]));
6 | };
7 |
--------------------------------------------------------------------------------
/dev/vault/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM vault
2 |
3 | ENV GOOGLE_APPLICATION_CREDENTIALS="/dev/vault/service-account.json"
4 | ENV VAULT_ADDR='http://0.0.0.0:8200'
5 |
6 | CMD ["vault", "server", "-config", "/dev/vault/vault.config.json"]
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | /build
3 | /*.log
4 |
5 | functions/tmp/*
6 |
7 | service-account.json
8 | config.json
9 | environment.js
10 |
11 | .vscode
12 |
13 | *.log
14 | bin
15 |
16 | .firebaserc
17 | .firebase
18 |
--------------------------------------------------------------------------------
/app/src/assets/svg/add.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/src/datastore/actions/setSearchResults.action.js:
--------------------------------------------------------------------------------
1 | export function setSearchResults({ selecting }, searchResults) {
2 | if (searchResults) {
3 | selecting = true;
4 | }
5 | return { searchResults, selecting };
6 | }
7 |
--------------------------------------------------------------------------------
/app/src/components/views/tags.scss:
--------------------------------------------------------------------------------
1 | .tagsView {
2 | min-height: calc(100vh - 5rem);
3 | }
4 |
5 | .header {
6 | font-size: 1.5rem;
7 | font-style: italic;
8 | font-weight: 800;
9 | text-align: left;
10 | }
11 |
--------------------------------------------------------------------------------
/bin/vault/copy-vault-keys.ps1:
--------------------------------------------------------------------------------
1 | docker-compose exec vault sh /dev/vault/bin/unseal.sh
2 | docker-compose exec vault sh /dev/vault/bin/copy.sh
3 | docker-compose exec vault sh /dev/vault/bin/seal.sh
4 |
5 | node "$PWD\bin\vault\expand-secrets.js"
--------------------------------------------------------------------------------
/app/src/assets/svg/menu.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/src/assets/svg/skip-next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM mhart/alpine-node:10
2 |
3 | WORKDIR /app
4 |
5 | COPY ./app/package.json package.json
6 | COPY ./app/yarn.lock yarn.lock
7 | RUN yarn --pure-lockfile
8 |
9 | ADD ./app /app
10 |
11 | RUN yarn && yarn build
12 |
--------------------------------------------------------------------------------
/.devcontainer.json:
--------------------------------------------------------------------------------
1 | // See https://aka.ms/vscode-remote/devcontainer.json for format details.
2 | {
3 | "dockerComposeFile": "docker-compose.yaml",
4 | "service": "workspace",
5 | "workspaceFolder": "/app",
6 | "extensions": ["esbenp.prettier-vscode"]
7 | }
--------------------------------------------------------------------------------
/app/functions/utils/index.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | adminUtil: require('./admin.util'),
3 | algoliaUtil: require('./algolia.util'),
4 | collectionsUtil: require('./collections.util'),
5 | environmentUtil: require('./environment.util'),
6 | };
7 |
--------------------------------------------------------------------------------
/app/src/assets/svg/view-carousel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/src/datastore/actions/addImage.action.js:
--------------------------------------------------------------------------------
1 | export function addImage({ images, tags }, image) {
2 | images = [image, ...images];
3 | if (image.tags) {
4 | image.tags.forEach(tag => tags.add(tag));
5 | }
6 |
7 | return { images, tags };
8 | }
9 |
--------------------------------------------------------------------------------
/app/functions/utils/admin.util.js:
--------------------------------------------------------------------------------
1 | const admin = require('firebase-admin');
2 | let singleton;
3 |
4 | module.exports = ({ firebase }) => {
5 | if (!singleton) {
6 | singleton = admin.initializeApp(firebase)
7 | }
8 | return singleton;
9 | };
10 |
--------------------------------------------------------------------------------
/app/storage.rules:
--------------------------------------------------------------------------------
1 | service firebase.storage {
2 | match /b/{bucket}/o {
3 | match /{environment} {
4 | match /uploads/{filename} {
5 | allow write: if request.auth.token.admin == true;
6 | allow read;
7 | }
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/dev/workspace/.zshrc:
--------------------------------------------------------------------------------
1 | # Lines to append to .zshrc from deltaepsilon/dotfiles
2 | # Don't leave blank lines... it copies poorly
3 | export NVM_DIR="$HOME/.nvm"
4 | [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm
5 |
6 | # Aliases
7 | alias ll="ls -al"
--------------------------------------------------------------------------------
/app/src/assets/svg/delete.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/src/components/views/tags.view.scss:
--------------------------------------------------------------------------------
1 | .tagsView {
2 | min-height: calc(100vh - 5rem);
3 | width: calc(100vw - 3rem);
4 | }
5 |
6 | .header {
7 | font-size: 1.5rem;
8 | font-style: italic;
9 | font-weight: 800;
10 | text-align: left;
11 | }
12 |
--------------------------------------------------------------------------------
/app/src/datastore/actions/removeSelection.action.js:
--------------------------------------------------------------------------------
1 | export function removeSelection({ selection, selecting }, id) {
2 | selection.delete(id);
3 | if (!selection.size) {
4 | selecting = false;
5 | }
6 | return { selection: new Set(selection), selecting };
7 | }
8 |
--------------------------------------------------------------------------------
/app/firestore.indexes.json:
--------------------------------------------------------------------------------
1 | {
2 | "indexes": [
3 | {
4 | "collectionId": "uploads",
5 | "fields": [
6 | { "fieldPath": "isProduction", "mode": "ASCENDING" },
7 | { "fieldPath": "created", "mode": "DESCENDING" }
8 | ]
9 | }
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/app/src/assets/svg/clear.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/src/datastore/actions/addSelection.action.js:
--------------------------------------------------------------------------------
1 | export function addSelection({ selection }, ids) {
2 | if (typeof ids == 'string') {
3 | ids = [ids];
4 | }
5 |
6 | ids.forEach(id => id && selection.add(id));
7 | return { selection: new Set(selection), selecting: true };
8 | }
9 |
--------------------------------------------------------------------------------
/app/src/queries/index.js:
--------------------------------------------------------------------------------
1 | export * from './deleteImage.query';
2 | export * from './image.query';
3 | export * from './images.query';
4 | export * from './imagesByTag.query';
5 | export * from './imageVersion.query';
6 | export * from './updateImage.query';
7 | export * from './updateTags.query';
8 |
--------------------------------------------------------------------------------
/app/src/assets/svg/open-with.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/src/components/views/embed.view.scss:
--------------------------------------------------------------------------------
1 | .embedView {
2 | display: flex;
3 | flex-direction: column;
4 | align-items: center;
5 |
6 | textarea {
7 | width: calc(100vw - 8rem);
8 | height: 2rem;
9 | }
10 |
11 | iframe, textarea {
12 | margin: 1rem;
13 | }
14 | }
--------------------------------------------------------------------------------
/app/src/datastore/actions/addImages.action.js:
--------------------------------------------------------------------------------
1 | export function addImages({ images }, newImages) {
2 | images = [...images, ...newImages];
3 |
4 | const tags = new Set();
5 | images.forEach(image => image.tags && image.tags.forEach(tag => tags.add(tag)));
6 | return { images, tags };
7 | }
8 |
--------------------------------------------------------------------------------
/app/src/datastore/actions/setControlSelect.action.js:
--------------------------------------------------------------------------------
1 | export function setControlSelect({ controlSelect, shiftSelect }, value) {
2 | if (!!value) {
3 | shiftSelect = false;
4 | }
5 |
6 | if (value != controlSelect) {
7 | return { controlSelect: !!value, shiftSelect };
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/app/src/datastore/actions/setShiftSelect.action.js:
--------------------------------------------------------------------------------
1 | export function setShiftSelect({ shiftSelect, controlSelect }, value) {
2 | if (!!value) {
3 | controlSelect = false;
4 | }
5 |
6 | if (value != shiftSelect) {
7 | return { shiftSelect: !!value, controlSelect };
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/dev/vault/vault.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "storage": {
3 | "gcs": {
4 | "bucket": "quiver-vault"
5 | }
6 | },
7 | "listener": {
8 | "tcp": {
9 | "address": "0.0.0.0:8200",
10 | "tls_disable": true
11 | }
12 | },
13 | "disable_mlock": true,
14 | "ui": true
15 | }
16 |
--------------------------------------------------------------------------------
/app/src/assets/svg/check-circle.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/src/queries/deleteImage.query.js:
--------------------------------------------------------------------------------
1 | export async function deleteImageQuery({ environment, id }) {
2 | const uploads = environment.collections.uploads;
3 | const doc = window.firebase
4 | .firestore()
5 | .collection(uploads)
6 | .doc(id);
7 |
8 | await doc.update({ deleted: true });
9 | }
10 |
--------------------------------------------------------------------------------
/app/src/assets/svg/content-copy.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/functions/utils/algolia.util.js:
--------------------------------------------------------------------------------
1 | const algoliasearch = require('algoliasearch');
2 | let singleton;
3 |
4 | module.exports = ({ algolia }) => {
5 | if (!singleton) {
6 | const { applicationId, apiKey } = algolia;
7 | singleton = algoliasearch(applicationId, apiKey);
8 | }
9 | return singleton;
10 | };
11 |
--------------------------------------------------------------------------------
/cloudbuild.yaml:
--------------------------------------------------------------------------------
1 | steps:
2 | - name: 'gcr.io/cloud-builders/docker'
3 | args: ['build', '-t', 'us.gcr.io/$PROJECT_ID/fogo:latest', '.']
4 | - name: 'us.gcr.io/$PROJECT_ID/fogo:latest'
5 | dir: '/app'
6 | args: ['yarn', 'ci:deploy', '--token', '$_FIREBASE_TOKEN']
7 | images: ['us.gcr.io/$PROJECT_ID/fogo:latest']
8 | timeout: 3600s
9 |
--------------------------------------------------------------------------------
/app/firestore.rules:
--------------------------------------------------------------------------------
1 | service cloud.firestore {
2 | match /databases/{database}/documents {
3 | match /uploads/{document=**} {
4 | allow write: if request.auth.token.admin == true ;
5 | allow read;
6 | }
7 |
8 | match /users/{document=**} {
9 | allow read, write: if request.auth.token.admin == true ;
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/app/src/components/drawer/drawer.scss:
--------------------------------------------------------------------------------
1 | .wrapper {
2 | position: relative;
3 | z-index: 100;
4 | }
5 |
6 | .drawer {
7 | .list {
8 | margin-top: 4rem;
9 | }
10 |
11 | .item {
12 | font-family: 'mr-eaves-modern' !important;
13 | font-size: 2rem !important;
14 | font-weight: 800;
15 | font-style: italic;
16 | }
17 | }
--------------------------------------------------------------------------------
/app/src/components/views/images.view.scss:
--------------------------------------------------------------------------------
1 | .imagesView {
2 | min-height: calc(100vh - 5rem);
3 | }
4 |
5 | .header {
6 | font-size: 1.5rem;
7 | font-style: italic;
8 | font-weight: 800;
9 | text-align: left;
10 | }
11 |
12 | .fab {
13 | position: fixed !important;
14 | right: 2rem;
15 | bottom: 2rem;
16 | z-index: 9;
17 | }
18 |
--------------------------------------------------------------------------------
/app/src/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Fogo",
3 | "short_name": "Fogo",
4 | "start_url": "/",
5 | "display": "standalone",
6 | "orientation": "portrait",
7 | "background_color": "#fff",
8 | "theme_color": "#29b6f6",
9 | "icons": [
10 | {
11 | "src": "/assets/icon.png",
12 | "type": "image/png",
13 | "sizes": "512x512"
14 | }
15 | ]
16 | }
--------------------------------------------------------------------------------
/bin/vault/copy-vault-keys.sh:
--------------------------------------------------------------------------------
1 | CURRENT_PATH="`dirname \"$0\"`"
2 | CURRENT_PATH="`( cd \"$CURRENT_PATH\" && pwd )`"
3 | if [ -z "$CURRENT_PATH" ] ; then
4 | exit 1
5 | fi
6 |
7 | docker-compose exec vault sh /dev/vault/bin/unseal.sh
8 | docker-compose exec vault sh /dev/vault/bin/copy.sh
9 | docker-compose exec vault sh /dev/vault/bin/seal.sh
10 | node $CURRENT_PATH/expand-secrets.js
--------------------------------------------------------------------------------
/app/functions/utils/environment.util.spec.js:
--------------------------------------------------------------------------------
1 | const functions = require('firebase-functions');
2 | const environmentUtil = require('./environment.util');
3 |
4 | describe('environmentUtil', () => {
5 | it('should read development environment', () => {
6 | const environment = environmentUtil(functions);
7 | expect(typeof environment.firebase).toEqual('object');
8 | });
9 | });
10 |
--------------------------------------------------------------------------------
/functions/utils/environment.util.spec.js:
--------------------------------------------------------------------------------
1 | const functions = require('firebase-functions');
2 | const environmentUtil = require('./environment.util');
3 |
4 | describe('environmentUtil', () => {
5 | it('should read development environment', () => {
6 | const environment = environmentUtil(functions);
7 | expect(typeof environment.firebase).toEqual('object');
8 | });
9 | });
10 |
--------------------------------------------------------------------------------
/app/src/datastore/actions/setSearching.action.js:
--------------------------------------------------------------------------------
1 | export function setSearching({ searchResults, selection, selecting }, searching) {
2 | if (searching) {
3 | selection = new Set();
4 |
5 | if (searchResults) {
6 | selecting = true;
7 | }
8 | } else {
9 | searchResults = null
10 | }
11 | return { searching: !!searching, search: '', searchResults, selection, selecting };
12 | }
13 |
--------------------------------------------------------------------------------
/app/src/assets/svg/search.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/src/components/views/galleries.view.scss:
--------------------------------------------------------------------------------
1 | .galleriesView {
2 | min-height: calc(100vh - 8rem);
3 | width: calc(100vw - 3rem);
4 |
5 | h3 {
6 | font-family: 'mr-eaves-modern' !important;
7 | font-size: 2rem !important;
8 | font-weight: 800;
9 | font-style: italic;
10 | text-transform: uppercase;
11 | }
12 | }
13 |
14 | .row {
15 | display: flex;
16 | align-items: center;
17 |
18 | a {
19 | margin-right: 1rem;
20 | }
21 | }
--------------------------------------------------------------------------------
/app/src/queries/image.query.js:
--------------------------------------------------------------------------------
1 | export async function imageQuery(environment, id) {
2 | const uploads = environment.collections.uploads;
3 | const ref = window.firebase
4 | .firestore()
5 | .collection(uploads)
6 | .doc(id);
7 | const doc = await ref.get();
8 | const image = {
9 | __id: doc.id,
10 | ...doc.data(),
11 | };
12 |
13 | if (image.tags) {
14 | image.tags = Object.keys(image.tags);
15 | }
16 |
17 | return image;
18 | }
19 |
--------------------------------------------------------------------------------
/app/src/components/views/tags.view.js:
--------------------------------------------------------------------------------
1 | import style from './tags.view.scss';
2 | import { connect } from 'unistore';
3 | import { actions } from '../../datastore';
4 |
5 | import Tags from '../tags/tags.component';
6 |
7 | const TagsView = connect('selection', actions)(({ selection }) => (
8 |
9 |
10 |
11 |
12 | ));
13 |
14 | export { TagsView };
15 |
--------------------------------------------------------------------------------
/app/src/assets/svg/share.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/firebase.json:
--------------------------------------------------------------------------------
1 | {
2 | "database": {
3 | "rules": "database.rules.bolt"
4 | },
5 | "firestore": {
6 | "rules": "firestore.rules",
7 | "indexes": "firestore.indexes.json"
8 | },
9 | "hosting": {
10 | "public": "build",
11 | "ignore": [
12 | "firebase.json",
13 | "**/.*",
14 | "**/node_modules/**"
15 | ],
16 | "rewrites": [
17 | {
18 | "source": "**",
19 | "destination": "/index.html"
20 | }
21 | ]
22 | },
23 | "storage": {
24 | "rules": "storage.rules"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/app/src/datastore/actions/updateImage.action.js:
--------------------------------------------------------------------------------
1 | export function updateImage({ environment, images, image }, updatedImage) {
2 | const { __id: id } = updatedImage;
3 | const imageIndex = images.findIndex(image => image.__id == id);
4 |
5 | images = images.slice(0);
6 |
7 | if (image && image.__id == id) {
8 | image = updatedImage;
9 | }
10 |
11 | images[imageIndex] = updatedImage;
12 |
13 | const tags = new Set();
14 | images.forEach(image => image.tags && image.tags.forEach(tag => tags.add(tag)));
15 |
16 | return { images, image, tags };
17 | }
18 |
--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | workspace:
4 | build: ./dev/workspace
5 | env_file: ./dev/workspace/env.list
6 | ports:
7 | - '3000:3000'
8 | - '8080:8080'
9 | - '41000:41000'
10 | volumes:
11 | - './app:/app'
12 | vault:
13 | container_name: vault
14 | build: ./dev/vault
15 | env_file: ./dev/vault/env.list
16 | volumes:
17 | - ./dev/vault:/dev/vault
18 | - ./app/vault:/app/vault
19 | ports:
20 | - 8200:8200
21 | cap_add:
22 | - IPC_LOCK
23 |
24 |
--------------------------------------------------------------------------------
/app/src/queries/updateImage.query.js:
--------------------------------------------------------------------------------
1 | import { imageQuery } from './image.query';
2 | import { mappedActions } from '../datastore';
3 | const { updateImage } = mappedActions;
4 |
5 | export async function updateImageQuery({ environment, image }) {
6 | const { __id: id, description } = image;
7 | const uploads = environment.collections.uploads;
8 | const doc = window.firebase
9 | .firestore()
10 | .collection(uploads)
11 | .doc(id);
12 |
13 | await doc.set({ description }, { merge: true });
14 | const updatedImage = await imageQuery(environment, id);
15 | updateImage(updatedImage);
16 | }
17 |
--------------------------------------------------------------------------------
/app/src/components/nav/nav.scss:
--------------------------------------------------------------------------------
1 | .mdc-toolbar {
2 | opacity: 0.9;
3 | position: fixed !important;
4 | z-index: 10;
5 |
6 | .mdc-toolbar__section {
7 | align-items: center;
8 | }
9 |
10 | .mdc-toolbar__title {
11 | font-family: 'mr-eaves-modern' !important;
12 | font-size: 2rem !important;
13 | font-weight: 800;
14 | font-style: italic;
15 | }
16 | }
17 |
18 | .toolbarIcon {
19 | cursor: pointer;
20 | }
21 |
22 | .actions {
23 | display: flex;
24 | align-items: center;
25 | padding: 0 2rem;
26 | flex-grow: 1;
27 | }
28 |
29 | .icon {
30 | cursor: pointer;
31 | margin: 0 1rem;
32 | }
33 |
34 | .toolbarIcon {
35 | margin-left: 1rem;
36 | }
37 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@quiver/fogo",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "repository": "https://github.com/how-to-firebase/fogo.git",
6 | "author": "Chris Esplin ",
7 | "private": true,
8 | "scripts": {
9 | "build": "docker-compose build",
10 | "dev": "docker-compose build workspace && docker-compose run --service-ports --rm workspace zsh",
11 | "ci:login": "npx firebase login:ci --no-localhost",
12 | "ci:build": "docker build --tag=fogo .",
13 | "ci:interactive": "docker run -it --rm fogo sh",
14 | "ci:latest": "docker run -it --rm gcr.io/fir-consulting-2019/fogo:latest sh",
15 | "windows:watch": "powershell ./bin/watch.ps1"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/app/src/datastore/actions/addImageVersion.action.js:
--------------------------------------------------------------------------------
1 | export function addImageVersion({ images, image, environment }, { versionName, url }) {
2 | const imageIndex = images.findIndex(image => image.__id == record);
3 | const clone = { ...images[imageIndex] };
4 |
5 | images = images.slice(0);
6 |
7 | clone.versions = { ...clone.versions, [versionName]: { url } };
8 |
9 | if (!clone.versions[versionName].url) {
10 | console.error('url missing!', url);
11 | clone.versions[versionName].url = null;
12 | }
13 |
14 | if (image && image.__id == record) {
15 | image = { ...clone };
16 | }
17 |
18 | images[imageIndex] = clone;
19 |
20 | return { images, image, timestamp: Date.now() };
21 | }
22 |
--------------------------------------------------------------------------------
/app/src/queries/imagesByTag.query.js:
--------------------------------------------------------------------------------
1 | export async function imagesByTagQuery({ environment, tag }) {
2 | const uploads = environment.collections.uploads;
3 | const query = window.firebase
4 | .firestore()
5 | .collection(uploads)
6 | .where('environment', '==', environment.environment)
7 | .where(`tags.${tag}`, '==', true);
8 |
9 | // Don't use an orderBy here because it requires a new index for every tag... which is insane!
10 |
11 | const snapshot = await query.get();
12 | const results = snapshot.docs
13 | .map(doc => ({
14 | __id: doc.id,
15 | ...doc.data(),
16 | }))
17 | .sort((a, b) => {
18 | return a.filename > b.filename ? 1 : -1;
19 | });
20 |
21 | return results;
22 | }
23 |
--------------------------------------------------------------------------------
/app/src/components/views/embed.view.js:
--------------------------------------------------------------------------------
1 | import style from './embed.view.scss';
2 | import { connect } from 'unistore';
3 | import { actions } from '../../datastore';
4 |
5 | export function EmbedView({ tag }) {
6 | return (
7 |
8 |
11 |
12 |
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/app/src/datastore/actions/index.js:
--------------------------------------------------------------------------------
1 | export * from './addImage.action';
2 | export * from './addImages.action';
3 | export * from './addSelection.action';
4 | export * from './clearSelection.action';
5 | export * from './deleteSelection.action';
6 | export * from './removeSelection.action';
7 | export * from './setCurrentUser.action';
8 | export * from './setImage.action';
9 | export * from './setImages.action';
10 | export * from './setImagesAllLoaded.action';
11 | export * from './setImagesWidth.action';
12 | export * from './setPath.action';
13 | export * from './setSearch.action';
14 | export * from './setSearching.action';
15 | export * from './setSearchResults.action';
16 | export * from './setSelecting.action';
17 | export * from './setToken.action';
18 | export * from './toggleMenu.action';
19 | export * from './updateImage.action';
20 |
--------------------------------------------------------------------------------
/app/src/queries/updateTags.query.js:
--------------------------------------------------------------------------------
1 | import { imageQuery } from './image.query';
2 | import { mappedActions } from '../datastore';
3 | import { batch } from '../utils';
4 | const { updateImage } = mappedActions;
5 | const addToBatch = batch('update');
6 |
7 | export async function updateTagsQuery({ environment, id, tags }) {
8 | const uploads = environment.collections.uploads;
9 | const doc = window.firebase
10 | .firestore()
11 | .collection(uploads)
12 | .doc(id);
13 |
14 | if (!tags.size) {
15 | tags = null;
16 | } else {
17 | tags = Array.from(tags).reduce((tags, tag) => {
18 | tags[tag] = true;
19 | return tags;
20 | }, {});
21 | }
22 |
23 | await addToBatch(doc, { tags });
24 | const updatedImage = await imageQuery(environment, id);
25 | updateImage(updatedImage);
26 | }
27 |
--------------------------------------------------------------------------------
/preact.config.js:
--------------------------------------------------------------------------------
1 | export default function(config, env, helpers) {
2 | // https://gist.github.com/developit/08acd182a30e66eda8de01dbbe9725ba
3 | let babel = helpers.getLoadersByName(config, 'babel-loader')[0].rule.options;
4 |
5 | // this doesn't seem to work anymore:
6 | babel.presets[0][1].exclude.push('transform-async-to-generator', 'transform-regenerator');
7 |
8 | babel.plugins.push([
9 | 'fast-async',
10 | {
11 | env: {
12 | log: true,
13 | },
14 | compiler: {
15 | promises: true,
16 | noRuntime: true,
17 | },
18 | },
19 | ]);
20 |
21 | // turn off uglify to see the output without breaking the build:
22 | // let uglify = helpers.getPluginsByName(config, 'UglifyJsPlugin')[0];
23 | // if (uglify) {
24 | // config.plugins.splice(uglify.index, 1);
25 | // }
26 | }
27 |
--------------------------------------------------------------------------------
/app/preact.config.js:
--------------------------------------------------------------------------------
1 | export default function(config, env, helpers) {
2 | // https://gist.github.com/developit/08acd182a30e66eda8de01dbbe9725ba
3 | let babel = helpers.getLoadersByName(config, 'babel-loader')[0].rule.options;
4 |
5 | // this doesn't seem to work anymore:
6 | babel.presets[0][1].exclude.push('transform-async-to-generator', 'transform-regenerator');
7 |
8 | babel.plugins.push([
9 | 'fast-async',
10 | {
11 | env: {
12 | log: true,
13 | },
14 | compiler: {
15 | promises: true,
16 | noRuntime: true,
17 | },
18 | },
19 | ]);
20 |
21 | // turn off uglify to see the output without breaking the build:
22 | // let uglify = helpers.getPluginsByName(config, 'UglifyJsPlugin')[0];
23 | // if (uglify) {
24 | // config.plugins.splice(uglify.index, 1);
25 | // }
26 | }
27 |
--------------------------------------------------------------------------------
/app/src/components/views/galleries.view.js:
--------------------------------------------------------------------------------
1 | import style from './galleries.view.scss';
2 | import { connect } from 'unistore';
3 | import { actions } from '../../datastore';
4 |
5 | const GalleriesView = connect('tags', actions)(({ tags }) => (
6 |
7 | {getItems(tags)}
8 |
9 | ));
10 |
11 | function getItems(tags) {
12 | return Array.from(tags).map(tag => (
13 |
22 | ));
23 | }
24 |
25 | export { GalleriesView };
26 |
--------------------------------------------------------------------------------
/bin/vault/expand-secrets.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 | const pathPrefix = [__dirname, '..', '..', 'app', 'vault'].join('/');
4 | const variablesPath = path.resolve(pathPrefix, 'variables.json');
5 | const { data: secrets } = require(path.resolve(pathPrefix, 'secrets.json'));
6 | const { variables, files } = Object.keys(secrets).reduce(
7 | (acc, key) => {
8 | const isFile = key.match(/\./);
9 | const value = secrets[key];
10 |
11 | isFile ? acc.files.push([key, value]) : (acc.variables[key] = value);
12 |
13 | return acc;
14 | },
15 | { variables: {}, files: [] }
16 | );
17 |
18 | files.forEach(([key, value]) => {
19 | const filePath = path.resolve(pathPrefix, key);
20 |
21 | fs.writeFileSync(filePath, JSON.stringify(value), 'base64');
22 |
23 | console.log('wrote', filePath);
24 | });
25 |
26 | fs.writeFileSync(variablesPath, JSON.stringify(variables), 'utf8');
27 | console.log('wrote', variablesPath);
28 |
--------------------------------------------------------------------------------
/app/src/observers/images.observer.js:
--------------------------------------------------------------------------------
1 | import { Observable } from 'rxjs/Observable';
2 |
3 | export function imagesObserver({ environment, lastCreated }) {
4 | return Observable.create(observer => {
5 | const uploads = environment.collections.uploads;
6 | const orderedCollection = window.firebase
7 | .firestore()
8 | .collection(uploads)
9 | .where('environment', '==', environment.environment)
10 | .where('created', '>', lastCreated)
11 | .orderBy('created', 'desc');
12 |
13 | let laggedIds = new Set();
14 | return orderedCollection.onSnapshot(snapshot => {
15 | const results = snapshot.docs
16 | .filter(doc => !laggedIds.has(doc.id))
17 | .map(doc => ({ __id: doc.id, ...doc.data() }));
18 |
19 | results.forEach(({ __id }) => laggedIds.add(__id));
20 |
21 | laggedIds = new Set(snapshot.docs.map(doc => doc.id));
22 |
23 | observer.next(results);
24 | });
25 | });
26 | }
27 |
--------------------------------------------------------------------------------
/app/functions/config.json.dist:
--------------------------------------------------------------------------------
1 | {
2 | "apiKeys": {
3 | "googleUrlShortener": "asdfasdfasdfasdfasdf"
4 | },
5 | "algolia": {
6 | "applicationId": "ASDFASDFADSFDSF",
7 | "apiKey": "asdfadsfdsfasfds",
8 | "settings": {
9 | "attributesForFaceting": ["filterOnly(environment)"],
10 | "searchableAttributes": ["filename", "tags"]
11 | }
12 | },
13 | "collections": {
14 | "uploads": "uploads",
15 | "users": "users"
16 | },
17 | "firebase": {
18 | "databaseURL": "https://your-firebase.firebaseio.com",
19 | "storageBucket": "your-firebase.appspot.com",
20 | "apiKey": "asdfsdfdsfasdfadsfsdf",
21 | "authDomain": "your-firebase.firebaseapp.com",
22 | "projectId": "your-firebase",
23 | "serviceAccount": "./service-account.json"
24 | },
25 |
26 | "indexes": {
27 | "uploads": "fogo-uploads"
28 | },
29 | "refs": {
30 | "idTokenRefresh": "notifications/{uid}/idTokenRefresh"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/app/functions/auth/customClaim.onCreate.js:
--------------------------------------------------------------------------------
1 | const { adminUtil, collectionsUtil } = require('../utils');
2 |
3 | module.exports = ({ environment }) => event => {
4 | const user = event.data;
5 | const admin = adminUtil(environment);
6 | const idTokenRefreshPath = environment.refs.idTokenRefresh.replace(/\{uid\}/, user.uid);
7 | const idTokenRefreshRef = admin.database().ref(idTokenRefreshPath);
8 | const collection = admin.firestore().collection(environment.collections.users);
9 | const query = collection.where('isAdmin', '==', true);
10 |
11 | return query
12 | .get()
13 | .then(snapshot => {
14 | const emails = new Set(snapshot.docs.map(doc => doc.data().email));
15 | return (
16 | emails.has(user.email) &&
17 | admin
18 | .auth()
19 | .setCustomUserClaims(user.uid, { admin: true })
20 | .then(() => true)
21 | );
22 | })
23 | .then(isAdmin => idTokenRefreshRef.set(Date.now()).then(() => isAdmin));
24 | };
25 |
--------------------------------------------------------------------------------
/app/src/queries/images.query.js:
--------------------------------------------------------------------------------
1 | export async function imagesQuery({ environment, cursor, images, limit = 10 }) {
2 | const uploads = environment.collections.uploads;
3 | const orderedCollection = window.firebase
4 | .firestore()
5 | .collection(uploads)
6 | .where('environment', '==', environment.environment)
7 | .orderBy('created', 'desc');
8 | const limitedCollection = orderedCollection.limit(+limit);
9 | const query = (cursor && limitedCollection.startAfter(cursor.created)) || limitedCollection;
10 |
11 | const snapshot = await query.get();
12 | const results = snapshot.docs
13 | .map(doc => {
14 | const image = {
15 | __id: doc.id,
16 | ...doc.data(),
17 | };
18 | if (image.tags) {
19 | image.tags = Object.keys(image.tags);
20 | }
21 | return image;
22 | })
23 | .filter(({ deleted }) => !deleted);
24 |
25 | return {
26 | results,
27 | imagesAllLoaded: results.length < +limit,
28 | };
29 | }
30 |
--------------------------------------------------------------------------------
/app/src/components/guard/guard.component.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'unistore';
2 | import { store, actions } from '../../datastore';
3 | import { route } from 'preact-router';
4 | import Match from 'preact-router/match';
5 |
6 | function evaluatePath({ path, laggedPath, currentUser }) {
7 | const publicPages = ['embed', 'gallery'];
8 | const parts = new Set(path.split('/'));
9 | const needsToLogIn = !currentUser && !publicPages.find(page => parts.has(page));
10 | const isOAuthRedirect = !laggedPath && currentUser && path == '/login';
11 |
12 | if (needsToLogIn && path != '/login') {
13 | location.pathname = '/login';
14 | } else if (isOAuthRedirect) {
15 | route(laggedPath || '/');
16 | } else if (path == '/') {
17 | route('/images');
18 | }
19 | }
20 |
21 | export default connect('laggedPath,currentUser', actions)(({ laggedPath, currentUser }) => {
22 | return (
23 | {({ matches, path, url }) => evaluatePath({ path, laggedPath, currentUser })}
24 | );
25 | });
26 |
--------------------------------------------------------------------------------
/app/src/datastore/actions/deleteSelection.action.js:
--------------------------------------------------------------------------------
1 | import { deleteImageQuery } from '../../queries/deleteImage.query';
2 |
3 | export async function deleteSelection({ environment, selection, images }) {
4 | const ref = window.firebase.storage().ref();
5 | const records = Array.from(selection)
6 | .map(id => images.find(image => image.__id == id))
7 | .map(image => ({
8 | id: image.__id,
9 | ref: ref.child(image.name),
10 | }));
11 | const filteredImages = images.filter(image => !selection.has(image.__id));
12 |
13 | try {
14 | await Promise.all(
15 | records.map(async ({ id, ref }) => {
16 | await deleteImageQuery({ environment, id });
17 | return ref.delete();
18 | })
19 | );
20 | } catch (e) {
21 | console.log('e', e);
22 | console.error('Image deletion failed', records.map(({ id }) => id).join());
23 | }
24 |
25 | return {
26 | images: filteredImages,
27 | selection: new Set(),
28 | selecting: false,
29 | };
30 | }
31 |
--------------------------------------------------------------------------------
/app/data/test-MjQyNjY5M2NiNTYxM2M4MTkwZmY0YjNmZDdjM2E3NzI=.json:
--------------------------------------------------------------------------------
1 | {
2 | "etag": "CNGWnJHOk9gCEAE=",
3 | "resourceState": "exists",
4 | "timeCreated": "2017-12-18T12:56:48.889Z",
5 | "timeStorageClassUpdated": "2017-12-18T12:56:48.889Z",
6 | "name": "test/uploads/jpg.jpg",
7 | "metadata": { "firebaseStorageDownloadTokens": "0667faef-d190-412b-80d8-7ac7ebbe25c5" },
8 | "metageneration": "1",
9 | "bucket": "quiver-four.appspot.com",
10 | "id": "quiver-four.appspot.com/test/uploads/jpg.jpg/1513601808927569",
11 | "size": "5733",
12 | "storageClass": "STANDARD",
13 | "contentType": "image/jpeg",
14 | "md5Hash": "MjQyNjY5M2NiNTYxM2M4MTkwZmY0YjNmZDdjM2E3NzI=",
15 | "crc32c": "apiJtg==",
16 | "generation": "1513601808927569",
17 | "selfLink":
18 | "https://www.googleapis.com/storage/v1/b/quiver-four.appspot.com/o/test%2Fuploads%2Fjpg.jpg",
19 | "updated": "2017-12-18T12:56:48.889Z",
20 | "contentDisposition": "inline; filename*=utf-8''jpg.jpg",
21 | "mediaLink": "fakestuff",
22 | "kind": "storage#object",
23 | "isTest": true
24 | }
25 |
--------------------------------------------------------------------------------
/app/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 How To Firebase
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/app/functions/firestore/algolia.onWrite.js:
--------------------------------------------------------------------------------
1 | const { algoliaUtil } = require('../utils');
2 |
3 | let hasSetSettings = false;
4 |
5 | module.exports = ({ environment }) => (change, { params }) => {
6 | const client = algoliaUtil(environment);
7 | const index = client.initIndex(environment.indexes.uploads);
8 | const id = params.id;
9 | const data = change.after.data();
10 |
11 | let promise = Promise.resolve();
12 |
13 | if (!data) {
14 | promise = index.deleteObject(id);
15 | } else if (!data.isTest) {
16 | const { environment, filename, tags, versions } = data;
17 | const tagsArray = Object.keys(tags || {});
18 | const record = { objectID: id, environment, filename, versions };
19 |
20 | if (tagsArray.length) {
21 | record.tags = tagsArray;
22 | }
23 |
24 | promise = index.addObject(record);
25 | }
26 |
27 | return promise.then(() => {
28 | let promise = true;
29 | if (!hasSetSettings) {
30 | const { settings } = environment.algolia;
31 | hasSetSettings = true;
32 | promise = index.setSettings(settings);
33 | }
34 | return true;
35 | });
36 | };
37 |
--------------------------------------------------------------------------------
/app/functions/index.js:
--------------------------------------------------------------------------------
1 | const functions = require('firebase-functions');
2 | const { collectionsUtil, environmentUtil } = require('./utils');
3 | const environment = environmentUtil(functions);
4 | const uploads = collectionsUtil(environment).get('uploads');
5 |
6 | // Admin
7 | const { customClaimOnCreate } = require('./auth');
8 | exports.customClaimOnCreate = functions.auth.user().onCreate(customClaimOnCreate({ environment }));
9 |
10 | // Firestore
11 | const { algoliaOnWrite } = require('./firestore');
12 | exports.algoliaUploadsOnWrite = functions.firestore
13 | .document(`${uploads}/{id}`)
14 | .onWrite(algoliaOnWrite({ environment }));
15 |
16 | // Https
17 | const cors = require('cors');
18 | const corsFn = cors();
19 | // https://mhaligowski.github.io/blog/2017/03/10/cors-in-cloud-functions.html
20 | const corsMiddleware = fn => (req, res) => corsFn(req, res, () => fn(req, res));
21 |
22 | const { imageOnRequest } = require('./http');
23 | exports.image = functions.https.onRequest(corsMiddleware(imageOnRequest({ environment })));
24 |
25 | // Storage
26 | const { uploadsOnChange } = require('./storage');
27 | exports.uploadsOnChange = functions.storage.object().onFinalize(uploadsOnChange({ environment }));
28 |
--------------------------------------------------------------------------------
/app/src/components/views/images.view.js:
--------------------------------------------------------------------------------
1 | import style from './images.view.scss';
2 | import { connect } from 'unistore';
3 | import { actions } from '../../datastore';
4 | import { Link } from 'preact-router/match';
5 | import Fab from 'preact-material-components/Fab';
6 | import 'preact-material-components/Fab/style.css';
7 |
8 | import Images from '../images/images.component';
9 |
10 | // svg
11 | import add from '../../assets/svg/add.svg';
12 |
13 | const ImagesView = connect('environment,searchResults,isAdmin,searching', actions)(
14 | ({ environment, searchResults, isAdmin, searching }) => (
15 |
16 |
20 |
21 | {isAdmin && (
22 |
23 |
24 |
25 |
26 |
27 | )}
28 |
29 | )
30 | );
31 |
32 | export { ImagesView };
33 |
--------------------------------------------------------------------------------
/app/functions/utils/environment.util.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const admin = require('firebase-admin');
3 | const config = require('../config.json');
4 | const serviceAccountPath = `../${config.firebase.serviceAccount}`;
5 | const { clean } = require('@quiver/firebase-utilities');
6 |
7 | module.exports = functions => {
8 | if (!functions) {
9 | functions = require('firebase-functions');
10 | }
11 | if (!process.env.FIREBASE_CONFIG) {
12 | const serviceAccount = require(serviceAccountPath);
13 | const credential = admin.credential.cert(serviceAccount);
14 | process.env.FIREBASE_CONFIG = JSON.stringify(config.firebase);
15 | }
16 | if (!process.env.GOOGLE_APPLICATION_CREDENTIALS) {
17 | process.env.GOOGLE_APPLICATION_CREDENTIALS = path.resolve(__dirname, serviceAccountPath);
18 | }
19 |
20 | const nodeEnv = process.env.NODE_ENV;
21 | const env = clean({
22 | isProduction: nodeEnv == 'production' || undefined,
23 | isTest: nodeEnv == 'test' || undefined,
24 | isDevelopment: nodeEnv == 'development' || undefined,
25 | });
26 | const nativeConfig = functions.config();
27 | const firebaseConfig = Object.keys(nativeConfig).length ? nativeConfig : config.firebase;
28 |
29 | return Object.assign(config, firebaseConfig, { env, nodeEnv });
30 | };
31 |
--------------------------------------------------------------------------------
/app/src/components/search/search.scss:
--------------------------------------------------------------------------------
1 | .icon {
2 | cursor: pointer;
3 | margin: 0 1rem;
4 | }
5 |
6 | .searchBar {
7 | display: flex;
8 | flex-grow: 1;
9 | }
10 |
11 | .searchInput {
12 | flex-grow: 1;
13 | position: relative;
14 | opacity: 0;
15 |
16 | input {
17 | position: absolute;
18 | top: -20px;
19 | bottom: -20px;
20 | border: 0;
21 | outline: none;
22 | width: calc(100% - 5rem);
23 |
24 | padding: 0.5rem 2rem;
25 | font-size: 2rem;
26 | font-family: 'mr-eaves-modern', arial, sans-serif;
27 | font-weight: 800;
28 | font-style: italic;
29 |
30 | color: var(--mdc-theme-primary);
31 |
32 | &::placeholder {
33 | color: var(--mdc-theme-primary);
34 | }
35 | }
36 | }
37 |
38 | .searchByAlgolia {
39 | position: absolute;
40 | bottom: -20px;
41 | right: 20px;
42 | z-index: 1;
43 | }
44 |
45 | .clear {
46 | position: absolute;
47 | top: 0;
48 | right: 2rem;
49 | z-index: 1;
50 |
51 | cursor: pointer;
52 | }
53 |
54 | .searchBar[searching] {
55 | // position: fixed;
56 | // top: 0;
57 | // right: 0;
58 | // bottom: 0;
59 | // left: 0;
60 |
61 | .searchInput {
62 | opacity: 1;
63 | }
64 |
65 | input {
66 | background: white;
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/app/src/queries/imageVersion.query.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { imageQuery } from './image.query';
3 | import { mappedActions } from '../datastore';
4 | const { updateImage } = mappedActions;
5 |
6 | export async function imageVersionQuery({ environment, record, versionName }) {
7 | let imageUrl = `${environment.urls.image}?record=${record}`;
8 |
9 | if (versionName != 'original') {
10 | const isHeight = versionName[0] == 'x';
11 | const versionType = (isHeight && 'height') || 'width';
12 | const versionParam = (isHeight && versionName.slice(1)) || versionName;
13 | imageUrl += `&${versionType}=${versionParam}`;
14 | }
15 |
16 | const { data: url } = await axios.get(imageUrl);
17 | return url;
18 | }
19 |
20 | const loadingQueue = new Set();
21 | export async function loadImageVersionIfNecessary({
22 | environment,
23 | image,
24 | versionName = 'original',
25 | }) {
26 | const { name, __id: id } = image;
27 | if (!loadingQueue.has(name) && (!image.versions || !image.versions[versionName])) {
28 | loadingQueue.add(name);
29 | const url = await imageVersionQuery({ environment, record: id, versionName });
30 | const updatedImage = await imageQuery(environment, id);
31 | updateImage(updatedImage);
32 | loadingQueue.delete(name);
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/app/functions/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "functions",
3 | "description": "Cloud Functions for Firebase",
4 | "scripts": {
5 | "serve": "firebase serve --only functions",
6 | "shell": "firebase experimental:functions:shell",
7 | "start": "npm run shell",
8 | "deploy": "firebase deploy --only functions",
9 | "logs": "firebase functions:log",
10 | "test": "NODE_ENV=test jest",
11 | "test:watch": "NODE_ENV=test jest --watchAll",
12 | "debug": "NODE_ENV=test node --inspect --debug-brk --nolazy ./node_modules/jest/bin/jest.js --runInBand",
13 | "version": "node --version",
14 | "protocol": "legacy"
15 | },
16 | "dependencies": {
17 | "@google-cloud/storage": "^3.3.1",
18 | "@quiver/firebase-utilities": "^0.0.5",
19 | "algoliasearch": "^3.35.1",
20 | "child_process": "^1.0.2",
21 | "cors": "^2.8.5",
22 | "exif-parser": "^0.1.12",
23 | "exif-reader": "^1.0.3",
24 | "firebase-admin": "~8.6.0",
25 | "firebase-functions": "^3.2.0",
26 | "fs": "^0.0.1-security",
27 | "google-url": "^0.0.4",
28 | "path": "^0.12.7",
29 | "request": "^2.88.0"
30 | },
31 | "private": true,
32 | "devDependencies": {
33 | "jest": "^24.9.0",
34 | "node-mocks-http": "^1.8.0"
35 | },
36 | "engines": {
37 | "node": "8"
38 | },
39 | "jest": {
40 | "testEnvironment": "node"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/app/src/components/drawer/drawer.component.js:
--------------------------------------------------------------------------------
1 | import style from './drawer.scss';
2 | import { connect } from 'unistore';
3 | import { actions } from '../../datastore';
4 | import { Link } from 'preact-router/match';
5 |
6 | // Preact Material Components
7 | import Drawer from 'preact-material-components/Drawer';
8 | import List from 'preact-material-components/List';
9 | import 'preact-material-components/Drawer/style.css';
10 | import 'preact-material-components/List/style.css';
11 |
12 | export default connect('showMenu', actions)(({ showMenu, toggleMenu }) => (
13 |
14 |
15 |
16 |
17 |
18 | Images
19 |
20 |
21 | Galleries
22 |
23 |
24 | Sign Out
25 |
26 |
27 |
28 |
29 |
30 | ));
31 |
--------------------------------------------------------------------------------
/app/src/components/tags/tags.scss:
--------------------------------------------------------------------------------
1 | .form {
2 | display: flex;
3 | align-items: center;
4 | }
5 |
6 | .tagsInput {
7 | flex-grow: 1;
8 | }
9 |
10 | .globalTagItems,
11 | .tagItems {
12 | display: flex;
13 | flex-wrap: wrap;
14 | padding: 0 0 5rem;
15 | max-width: calc(100vw - 3rem);
16 | }
17 |
18 | .globalTagItemsTitle {
19 | margin: 4rem 0 0;
20 | }
21 |
22 | .list {
23 | padding: 0;
24 | list-style: none;
25 |
26 | li {
27 | display: flex;
28 | margin-bottom: 1rem;
29 | }
30 | }
31 |
32 | .secondary {
33 | padding: 0 1rem;
34 |
35 | h3 {
36 | margin-top: 0;
37 | }
38 | }
39 |
40 | .image {
41 | height: 200px;
42 | width: 200px;
43 | background-position: center center;
44 | background-repeat: no-repeat;
45 | background-color: #fafafa;
46 | }
47 |
48 | .tagItems {
49 | max-width: 500px;
50 | padding: 0;
51 | }
52 |
53 | .tagItem {
54 | display: flex;
55 | align-items: center;
56 |
57 | font-size: 1.5rem;
58 |
59 | background: #eee;
60 | border-radius: 22px;
61 | float: left;
62 | padding: 5px;
63 | margin: 0.5rem 1rem 0.5rem 0rem;
64 | padding: 0.5rem;
65 |
66 | img {
67 | background: var(--mdc-theme-secondary-dark);
68 | border-radius: 50%;
69 | cursor: pointer;
70 | padding: 4px;
71 | }
72 |
73 | span {
74 | display: inline-block;
75 | margin: 0 .5rem;
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/app/src/components/views/gallery.view.scss:
--------------------------------------------------------------------------------
1 | .galleryView {
2 | height: 100vh;
3 | width: 100vw;
4 |
5 | display: flex;
6 | justify-content: center;
7 | align-items: center;
8 |
9 | --color-transparent: rgba(255, 255, 255, .7);
10 | }
11 |
12 | .galleryView[focused] {
13 | i {
14 | visibility: visible;
15 | }
16 | }
17 |
18 | .image {
19 | max-height: calc(100vh - 1rem);
20 | max-width: calc(100vw - 1rem);
21 | }
22 |
23 | .details {
24 | background: var(--color-transparent);
25 | font-family: sans-serif;
26 | min-height: 3rem;
27 |
28 | position: absolute;
29 | bottom: 0rem;
30 | right: 0;
31 | left: 0;
32 |
33 | p {
34 | margin: 0 3rem;
35 | padding: 0.5rem;
36 | }
37 |
38 | i {
39 | position: absolute;
40 | bottom: 5px;
41 | right: 5.5rem;
42 | visibility: hidden;
43 | }
44 |
45 | aside {
46 | float: right;
47 | padding: 0.25rem 1rem;
48 | margin: 0;
49 |
50 | position: absolute;
51 | bottom: 0;
52 | right: 2.5rem;
53 | }
54 |
55 | img {
56 | background: var(--color-transparent);
57 | cursor: pointer;
58 | padding: 0.5rem;
59 |
60 | position: absolute;
61 | height: calc(100% - 1rem);
62 | bottom: 0;
63 | }
64 |
65 | .right {
66 | right: 0rem;
67 | }
68 |
69 | .left {
70 | left: 0rem;
71 | transform: rotate(180deg);
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/app/src/utils/batch.util.js:
--------------------------------------------------------------------------------
1 | const methods = new Set(['set', 'update', 'delete']);
2 |
3 | export function batch(method, timeout = 300) {
4 | if (!methods.has(method)) {
5 | throw new Error(`Method not valid: ${method}`);
6 | } else {
7 | let jobs = [];
8 | let timer;
9 |
10 | function execute(jobs) {
11 | const batch = window.firebase.firestore().batch();
12 | const localJobs = pullJobs(jobs);
13 |
14 | localJobs.forEach(({ ref, payload }) => batch[method](ref, payload));
15 | batch
16 | .commit()
17 | .then(() => resolveJobs(localJobs))
18 | .catch(error => {
19 | console.error('batch error', error);
20 | });
21 | }
22 |
23 | function pullJobs(jobs) {
24 | const result = [];
25 | while (jobs.length) {
26 | result.push(jobs.pop());
27 | }
28 | return result;
29 | }
30 |
31 | function resolveJobs(jobs) {
32 | jobs.forEach(({ resolve }) => resolve());
33 | }
34 |
35 | return function addToBatch(ref, payload) {
36 | return new Promise((resolve, reject) => {
37 | jobs.push({ ref, payload, resolve });
38 |
39 | if (timer) {
40 | clearTimeout(timer);
41 | }
42 |
43 | if (jobs.length == 4) {
44 | execute(jobs);
45 | } else {
46 | timer = setTimeout(() => execute(jobs), timeout);
47 | }
48 | });
49 | };
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/app/src/style.css:
--------------------------------------------------------------------------------
1 | @import url('https://use.typekit.net/vjz5ruy.css');
2 |
3 | html,
4 | body {
5 | font-family: 'mr-eaves-modern', arial, sans-serif;
6 | font-size: 16px;
7 | font-weight: 300;
8 | margin: 0;
9 |
10 | --mdc-theme-primary: #29b6f6;
11 | --mdc-theme-primary-light: #73e8ff;
12 | --mdc-theme-primary-dark: #0086c3;
13 | --mdc-theme-secondary: #ec407a;
14 | --mdc-theme-secondary-light: #ff77a9;
15 | --mdc-theme-secondary-dark: #b4004e;
16 | }
17 |
18 | input {
19 | font-family: 'mr-eaves-modern', arial, sans-serif;
20 | }
21 |
22 | a {
23 | text-decoration: none;
24 | }
25 |
26 | a,
27 | a:visited {
28 | color: inherit;
29 | }
30 |
31 | a.active a {
32 | color: white !important;
33 | cursor: default;
34 | background: gray;
35 | }
36 |
37 | h1 {
38 | text-align: center;
39 | }
40 |
41 | .app-wrapper #gallery-view {
42 | background: black;
43 | }
44 |
45 | .router-wrapper {
46 | min-height: calc(100vh - 6rem);
47 |
48 | display: flex;
49 | justify-content: center;
50 | align-items: center;
51 | padding-top: 4rem;
52 | }
53 |
54 | .firebase-authentication {
55 | max-width: calc(100vw - 2rem);
56 | }
57 |
58 | .storage-uploader {
59 | max-width: 43rem;
60 | margin: 2rem;
61 | }
62 |
63 | .storage-uploader .buttons {
64 | margin-bottom: 3rem;
65 | }
66 |
67 | .storage-uploader .mdc-grid-list {
68 | --mdc-theme-primary: rgba(0, 0, 0, 0.8);
69 | --mdc-grid-list-tile-width: 20rem;
70 | }
71 |
72 | .storage-uploader .mdc-grid-tile__primary-content {
73 | height: 156px !important;
74 | }
75 |
--------------------------------------------------------------------------------
/app/data/sample-storage-event.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": {
3 | "bucket": "quiver-four.appspot.com",
4 | "contentDisposition": "inline; filename*=utf-8''gif.gif",
5 | "contentType": "image/gif",
6 | "crc32c": "LdwUxw==",
7 | "etag": "CJTcx9ycjNgCEAE=",
8 | "generation": "1513348026986004",
9 | "id": "quiver-four.appspot.com/development/uploads/gif.gif/1513348026986004",
10 | "kind": "storage#object",
11 | "md5Hash": "NzNkZWI0ZTM2MjU0MmZlNjM0NWU4ZDc1Mjg5MGI4MjI=",
12 | "mediaLink":
13 | "https://www.googleapis.com/download/storage/v1/b/quiver-four.appspot.com/o/development%2Fuploads%2Fgif.gif?generation=1513348026986004&alt=media",
14 | "metadata": { "firebaseStorageDownloadTokens": "3a199f49-0f02-4d17-a3e4-43a50747d833" },
15 | "metageneration": "1",
16 | "name": "development/uploads/gif.gif",
17 | "resourceState": "exists",
18 | "selfLink":
19 | "https://www.googleapis.com/storage/v1/b/quiver-four.appspot.com/o/development%2Fuploads%2Fgif.gif",
20 | "size": "501962",
21 | "storageClass": "STANDARD",
22 | "timeCreated": "2017-12-15T14:27:06.655Z",
23 | "timeStorageClassUpdated": "2017-12-15T14:27:06.655Z",
24 | "updated": "2017-12-15T14:27:06.655Z"
25 | },
26 | "eventId": "10602881481377",
27 | "eventType": "providers/cloud.storage/eventTypes/object.change",
28 | "resource":
29 | "projects/_/buckets/quiver-four.appspot.com/objects/development/uploads/gif.gif#1513348026986004",
30 | "timestamp": "2017-12-15T14:27:07.203Z",
31 | "params": {}
32 | }
33 |
--------------------------------------------------------------------------------
/app/src/components/image-detail/imageDetail.scss:
--------------------------------------------------------------------------------
1 | .overlay {
2 | background: black;
3 | position: fixed;
4 | top: 0;
5 | right: 0;
6 | bottom: 0;
7 | left: 0;
8 |
9 | display: flex;
10 | flex-wrap: wrap;
11 | justify-content: center;
12 | align-items: start;
13 |
14 | z-index: 10;
15 | }
16 |
17 | .clear {
18 | position: fixed;
19 | top: 1rem;
20 | right: 1rem;
21 | background: white;
22 | padding: 1rem;
23 | border-radius: 50%;
24 | z-index: 1;
25 | }
26 |
27 | .image {
28 | max-height: 100vh;
29 | max-width: 100vw;
30 | }
31 |
32 | .description {
33 | background: rgba(0, 0, 0, .9);
34 | color: white;
35 | list-style: none;
36 | font-size: 1.5rem;
37 | padding: 1rem;
38 | margin: 0;
39 |
40 | position: absolute;
41 | bottom: 0;
42 |
43 | li {
44 | margin-bottom: 0.5rem;
45 | overflow: hidden;
46 | max-width: calc(100vw - 5rem);
47 | white-space: nowrap;
48 | text-overflow: ellipsis;
49 | }
50 |
51 | img {
52 | position: relative;
53 | top: 4px;
54 | }
55 |
56 | }
57 |
58 | .dimensions {
59 | display: inline-block;
60 | margin: 0 1rem;
61 | min-width: 8rem;
62 | }
63 |
64 | .copy {
65 | cursor: pointer;
66 | }
67 |
68 | .textarea {
69 | padding-top: 1rem;
70 | position: relative;
71 |
72 | textarea {
73 | width: 100%;
74 | }
75 | aside {
76 | position: absolute;
77 | right: 0;
78 | bottom: 1rem;
79 | z-index: 1;
80 |
81 | color: black;
82 | font-size: 1rem;
83 | }
84 | }
--------------------------------------------------------------------------------
/app/functions/storage/uploads.onChange.spec.js:
--------------------------------------------------------------------------------
1 | const uploadsOnChange = require('./uploads.onChange');
2 | const { adminUtil, collectionsUtil, environmentUtil } = require('../utils');
3 | const environment = environmentUtil();
4 | const admin = adminUtil(environment);
5 | const uploads = collectionsUtil(environment).get('uploads');
6 | const db = admin.firestore();
7 | const collection = db.collection(uploads);
8 |
9 | describe('uploadsOnChange', () => {
10 | let fn;
11 | beforeEach(() => {
12 | fn = uploadsOnChange({ environment });
13 | });
14 |
15 | // afterAll(done => {
16 | // collection
17 | // .where('isTest', '==', true)
18 | // .get()
19 | // .then(snapshot => {
20 | // const batch = db.batch();
21 | // snapshot.docs.forEach(doc => batch.delete(doc.ref));
22 | // return batch.commit();
23 | // });
24 | // });
25 |
26 | it('should ignore files outside an "upload" directory', done => {
27 | fn({ name: 'not-uploads/test.gif', md5Hash: 'not+a+hash/but-with+a+slash' }).then(
28 | ({ skipped }) => {
29 | expect(skipped).toEqual(true);
30 | done();
31 | }
32 | );
33 | });
34 |
35 | it('should process an upload', done => {
36 | const name = 'test-bypass/uploads/exif.jpg';
37 | fn({ name, resourceState: 'exists', md5Hash: 'not+a+hash/but-with+a+slash' }).then(result => {
38 | expect(result.name).toEqual(name);
39 | expect(result.isTest).toEqual(true);
40 | expect(result.environment).toEqual('test-bypass');
41 | done();
42 | });
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/app/src/observers/search.observer.js:
--------------------------------------------------------------------------------
1 | import { Observable } from 'rxjs/Observable';
2 | import { store } from '../datastore';
3 |
4 | export function searchObserver({ environment }) {
5 | const { applicationId, apiKey } = environment.algolia;
6 | const { algoliasearch } = window;
7 | const client = algoliasearch(applicationId, apiKey);
8 | const index = client.initIndex(environment.indexes.uploads);
9 | const timeout = 500;
10 |
11 | return Observable.create(observer => {
12 | let lastSearched;
13 | let timer;
14 | const unsubscribe = store.subscribe(({ search }) => {
15 | if (timer) {
16 | clearTimeout(timer);
17 | }
18 | if (search && search != lastSearched) {
19 | timer = setTimeout(() => {
20 | lastSearched = search;
21 | index
22 | .search(search, {
23 | facetFilters: `environment:${environment.environment}`,
24 | hitsPerPage: 100,
25 | })
26 | .then(results => {
27 | const hits = results.hits.map(result => ({
28 | __id: result.objectID,
29 | ...result,
30 | }));
31 | observer.next({ ...results, hits });
32 | });
33 | }, timeout);
34 | }
35 | });
36 |
37 | function bustCache() {
38 | client.clearCache();
39 | }
40 | window.addEventListener('search-cache-bust', bustCache);
41 |
42 | return () => {
43 | unsubscribe();
44 | window.removeEventListener('search-cache-bust', bustCache);
45 | };
46 | });
47 | }
48 |
--------------------------------------------------------------------------------
/app/src/assets/svg/three-dots.svg:
--------------------------------------------------------------------------------
1 |
2 |
34 |
--------------------------------------------------------------------------------
/app/src/environment.js.dist:
--------------------------------------------------------------------------------
1 | // Environment Variables
2 | const defaults = {
3 | algolia: {
4 | applicationId: 'ASDFADFASDF',
5 | apiKey: 'asdfasdfasdfadfaf',
6 | },
7 | collections: {
8 | uploads: 'uploads',
9 | },
10 | functionsEnvironment: 'production',
11 | indexes: {
12 | uploads: 'fogo-uploads',
13 | },
14 | refs: {
15 | idTokenRefresh: 'notifications/{uid}/idTokenRefresh',
16 | },
17 | urls: {
18 | image: 'https://your-cloud-functions-instance.cloudfunctions.net/image',
19 | },
20 | };
21 | const production = {
22 | ...defaults,
23 | environment: 'production',
24 | symbol: Symbol('production'),
25 | storage: { path: 'production/uploads' },
26 | };
27 | const howtofirebase = {
28 | ...defaults,
29 | environment: 'howtofirebase',
30 | symbol: Symbol('howtofirebase'),
31 | storage: { path: 'howtofirebase/uploads' },
32 | };
33 | const development = {
34 | ...defaults,
35 | environment: 'development',
36 | symbol: Symbol('development'),
37 | storage: { path: 'development/uploads' },
38 | };
39 |
40 | // Lookup Maps
41 | const environments = new Map([
42 | [production.symbol, production],
43 | [howtofirebase.symbol, howtofirebase],
44 | [development.symbol, development],
45 | ]);
46 | const hosts = new Map([
47 | ['yoursite.com', production.symbol],
48 | ['fogo.howtofirebase.com', howtofirebase.symbol],
49 | ['quiver-four.firebaseapp.com', howtofirebase.symbol],
50 | ['dev.chrisesplin.com', development.symbol],
51 | ['localhost', development.symbol],
52 | ]);
53 |
54 | // Lookup by location.hostname
55 | export default environments.get(hosts.get(location.hostname));
56 |
--------------------------------------------------------------------------------
/app/src/components/search/search.component.js:
--------------------------------------------------------------------------------
1 | import style from './search.scss';
2 | import { connect } from 'unistore';
3 | import { actions, mappedActions, store } from '../../datastore';
4 | import linkState from 'linkstate';
5 | import { searchObserver } from '../../observers';
6 |
7 | // svg
8 | import searchSvg from '../../assets/svg/search.svg';
9 | import clear from '../../assets/svg/clear.svg';
10 |
11 | const { environment } = store.getState();
12 | const { setSearchResults } = mappedActions;
13 | searchObserver({ environment }).subscribe(setSearchResults);
14 |
15 | export default connect('search,searching', actions)(
16 | ({ search, searching, setSearch, setSearching }) => {
17 | let input;
18 | function handleKeyup({ key }) {
19 | if (key == 'Escape') {
20 | if (input.value) {
21 | input.value = '';
22 | } else {
23 | setSearching(false);
24 | input.blur();
25 | }
26 | }
27 | }
28 |
29 | function clearInput() {
30 | input.value = '';
31 | input.focus();
32 | }
33 |
34 | return (
35 |
36 |
49 |
50 |

input.focus()} />
51 |
52 | );
53 | }
54 | );
55 |
--------------------------------------------------------------------------------
/app/src/assets/svg/spinner.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/dev/workspace/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM ubuntu:18.04
2 |
3 | RUN echo "rerun me"
4 |
5 | RUN apt update
6 |
7 | RUN apt install -y vim curl git-core zsh build-essential libssl-dev imagemagick
8 |
9 | # Install Yarn
10 | RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
11 | RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
12 | RUN apt install apt-transport-https
13 | RUN apt update && apt install --no-install-recommends yarn
14 |
15 | # Dotfiles
16 | RUN git clone https://github.com/deltaepsilon/dotfiles.git ~/dotfiles
17 | RUN git clone https://github.com/VundleVim/Vundle.vim.git ~/.vim/bundle/Vundle.vim
18 |
19 | WORKDIR /root
20 |
21 | # Install Oh My ZSH
22 | RUN sh -c "$(curl -fsSL https://raw.githubusercontent.com/robbyrussell/oh-my-zsh/master/tools/install.sh)" || true
23 | RUN rm .zshrc
24 |
25 | # Install NVM and set up Node
26 | ENV NVM_DIR /root/.nvm
27 | ENV NODE_VERSION lts/carbon
28 | RUN curl https://raw.githubusercontent.com/creationix/nvm/v0.33.8/install.sh | bash \
29 | && . $NVM_DIR/nvm.sh \
30 | && nvm install $NODE_VERSION \
31 | && nvm alias default $NODE_VERSION \
32 | && nvm use default
33 |
34 | ENV NODE_PATH $NVM_DIR/v$NODE_VERSION/lib/node_modules
35 | ENV PATH $NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH
36 |
37 | # Install gsutil
38 | RUN echo "deb http://packages.cloud.google.com/apt cloud-sdk-stretch main" | tee -a /etc/apt/sources.list.d/google-cloud-sdk.list
39 | RUN curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | apt-key add -
40 | RUN apt update && apt install -y google-cloud-sdk
41 |
42 | # Set up dotfiles
43 | WORKDIR /root/dotfiles
44 | RUN ./setup.sh
45 |
46 | WORKDIR /root
47 | RUN sed -i 's/\/Users\/quiver/\/root/g' .zshrc
48 |
49 | # Custom ZSH config
50 | COPY .zshrc .append-to-zshrc
51 | RUN sed -i 's/\r//' .append-to-zshrc
52 | RUN cat .append-to-zshrc >> .zshrc
53 |
54 | # Rock and Roll
55 | WORKDIR /app
56 |
57 | CMD [ "tail", "-f", "/dev/null" ]
--------------------------------------------------------------------------------
/app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@quiver/fogo",
3 | "version": "0.0.0",
4 | "license": "MIT",
5 | "scripts": {
6 | "start": "if-env NODE_ENV=production && npm run -s serve || npm run -s dev",
7 | "build": "rm -rf build && preact build --template template.html --no-prerender",
8 | "build:windows": "preact build --template template.html --no-prerender",
9 | "serve": "preact build --template template.html --no-prerender && preact serve",
10 | "dev": "preact watch --template template.html",
11 | "test": "NODE_ENV=test jest",
12 | "test:watch": "NODE_ENV=test jest --watch",
13 | "deploy": "yarn build && firebase deploy",
14 | "deploy:database": "firebase deploy --only database",
15 | "deploy:firestore": "firebase deploy --only firestore",
16 | "deploy:functions": "firebase deploy --only functions",
17 | "deploy:hosting": "yarn build && firebase deploy --only hosting",
18 | "deploy:storage": "firebase deploy --only storage",
19 | "lint": "eslint src"
20 | },
21 | "eslintConfig": {
22 | "extends": "eslint-config-synacor"
23 | },
24 | "eslintIgnore": [
25 | "build/*"
26 | ],
27 | "devDependencies": {
28 | "babel-plugin-fast-async": "^6.1.2",
29 | "eslint": "^5.16.0",
30 | "eslint-config-synacor": "^3.0.3",
31 | "firebase-tools": "^7.1.0",
32 | "if-env": "^1.0.0",
33 | "jest": "^24.7.1",
34 | "preact-cli": "^2.0.0",
35 | "uglifyjs-webpack-plugin": "^2.1.2"
36 | },
37 | "dependencies": {
38 | "@quiver/firebase-authentication": "^0.0.2",
39 | "@quiver/storage-uploader": "^0.0.4",
40 | "@reactivex/rxjs": "^5.5.3",
41 | "axios": "^0.18.0",
42 | "linkstate": "^1.1.0",
43 | "node-sass": "^4.11.0",
44 | "preact": "^8.4.2",
45 | "preact-cli-plugin-async": "^2.0.0",
46 | "preact-compat": "^3.18.4",
47 | "preact-material-components": "^1.3.2",
48 | "preact-router": "^2.6.0",
49 | "sass-loader": "^7.1.0",
50 | "unistore": "^3.4.1"
51 | },
52 | "jest": {
53 | "roots": [
54 | "src"
55 | ]
56 | },
57 | "private": true
58 | }
59 |
--------------------------------------------------------------------------------
/app/functions/firestore/algolia.onWrite.spec.js:
--------------------------------------------------------------------------------
1 | const algoliaOnWrite = require('./algolia.onWrite');
2 | const { adminUtil, algoliaUtil, collectionsUtil, environmentUtil } = require('../utils');
3 | const environment = environmentUtil();
4 | const admin = adminUtil(environment);
5 | const uploads = collectionsUtil(environment).get('uploads');
6 | const db = admin.firestore();
7 | const collection = db.collection(uploads);
8 |
9 | describe('algoliaOnWrite', () => {
10 | const setSettings = jest.fn(() => Promise.resolve());
11 | let client, initObject, addObject, deleteObject;
12 | beforeEach(() => {
13 | client = algoliaUtil(environment);
14 | addObject = jest.fn(() => Promise.resolve());
15 | deleteObject = jest.fn(() => Promise.resolve());
16 | addObject = jest.fn(() => Promise.resolve());
17 |
18 | jest
19 | .spyOn(client, 'initIndex')
20 | .mockImplementation(() => ({ addObject, deleteObject, setSettings }));
21 | });
22 |
23 | let fn;
24 | beforeEach(() => {
25 | fn = algoliaOnWrite({ environment });
26 | });
27 |
28 | const id = 'fake-id';
29 | const data = {
30 | environment: 'test env',
31 | filename: 'fake filename',
32 | versions: 'fake versions',
33 | };
34 | let params = { id };
35 | let change;
36 | beforeEach(() => {
37 | change = { after: { data: () => data } };
38 | });
39 |
40 | afterEach(() => client.initIndex.mockRestore());
41 |
42 | it('should call deleteObject', done => {
43 | fn({ after: { data: () => null } }, { params }).then(result => {
44 | expect(deleteObject).toHaveBeenCalledWith(id);
45 | done();
46 | });
47 | });
48 |
49 | it('should call addObject', done => {
50 | fn(change, { params }).then(result => {
51 | const record = Object.assign({ objectID: id }, data);
52 | expect(addObject).toHaveBeenCalledWith(record);
53 | done();
54 | });
55 | });
56 |
57 | it('should call setSettings exactly once', done => {
58 | Promise.all([fn(change, { params }), fn(change, { params })]).then(() => {
59 | expect(setSettings.mock.calls.length).toEqual(1);
60 | expect(setSettings).toHaveBeenCalledWith(environment.algolia.settings);
61 | done();
62 | });
63 | });
64 | });
65 |
--------------------------------------------------------------------------------
/app/src/datastore/index.js:
--------------------------------------------------------------------------------
1 | import { createStore } from 'unistore';
2 | import * as rawActions from './actions';
3 | import environment from '../environment';
4 |
5 | let startingState = {
6 | currentUser: null,
7 | environment,
8 | images: [],
9 | image: null,
10 | imagesAllLoaded: false,
11 | imagesObserver: null,
12 | laggedPath: null,
13 | laggedCurrentUser: null,
14 | listState: null,
15 | isAdmin: false,
16 | path: null,
17 | search: '',
18 | searching: false,
19 | searchResults: null,
20 | selecting: false,
21 | selection: new Set(),
22 | showMenu: false,
23 | tags: new Set(),
24 | timestamp: Date.now(),
25 | token: null,
26 | };
27 | const serialized = localStorage.getItem('fogo-state');
28 | if (serialized) {
29 | startingState = deserialize(serialized);
30 | const { images } = startingState;
31 | startingState.images = truncateImages(images);
32 | }
33 |
34 | const store = createStore(startingState);
35 |
36 | const actions = store => rawActions;
37 |
38 | const mappedActions = {};
39 | for (let i in rawActions) {
40 | mappedActions[i] = store.action(rawActions[i]);
41 | }
42 |
43 | store.subscribe(state => {
44 | const { laggedCurrentUser, currentUser } = state;
45 |
46 | if (currentUser) {
47 | const serialized = serialize(state);
48 | localStorage.setItem('fogo-state', serialized);
49 | } else if (laggedCurrentUser) {
50 | localStorage.removeItem('fogo-state');
51 | }
52 |
53 | window.state = state;
54 | });
55 |
56 | function serialize(state) {
57 | const { images, selection, selecting, tags } = state;
58 | const serialized = {
59 | images,
60 | selection: Array.from(selection),
61 | selecting,
62 | tags: Array.from(tags),
63 | };
64 | return JSON.stringify(serialized);
65 | }
66 |
67 | function deserialize(serialized) {
68 | const { images, selection: selectionArray, selecting, tags } = JSON.parse(
69 | serialized
70 | );
71 | return {
72 | ...startingState,
73 | images,
74 | selection: new Set(selectionArray),
75 | selecting,
76 | tags: new Set(tags),
77 | };
78 | }
79 |
80 | function truncateImages(images) {
81 | return images.slice(0, 25);
82 | }
83 |
84 | export { actions, mappedActions, store };
85 |
--------------------------------------------------------------------------------
/app/src/components/nav/nav.component.js:
--------------------------------------------------------------------------------
1 | import style from './nav.scss';
2 | import { connect } from 'unistore';
3 | import { actions } from '../../datastore';
4 | import { Link } from 'preact-router/match';
5 |
6 | // Preact Material Components
7 | import Button from 'preact-material-components/Button';
8 | import Toolbar from 'preact-material-components/Toolbar';
9 | import 'preact-material-components/Button/style.css';
10 | import 'preact-material-components/Toolbar/style.css';
11 |
12 | import Search from '../search/search.component';
13 |
14 | // svg
15 | import add from '../../assets/svg/add.svg';
16 | import deleteSvg from '../../assets/svg/delete.svg';
17 | import menu from '../../assets/svg/menu.svg';
18 |
19 | export default connect('isAdmin,path,selection,showMenu,token', actions)(
20 | ({
21 | isAdmin,
22 | path,
23 | selection,
24 | showMenu,
25 | token,
26 | toggleMenu,
27 | deleteSelection,
28 | addSelectionToGallery,
29 | }) => (
30 |
31 |
32 |
33 |
40 | Fogo
41 |
42 | {path == '/images' && [
43 |
,
44 |
45 | {isAdmin &&
46 | !!selection.size && [
47 |
48 |

54 | ,
55 |

,
61 | ]}
62 |
,
63 | ]}
64 |
65 |
66 |
67 |
68 | )
69 | );
70 |
--------------------------------------------------------------------------------
/app/template.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Fogo
7 |
8 |
9 |
10 |
11 |
12 | <% if (htmlWebpackPlugin.options.manifest.theme_color) { %>
13 |
14 | <% } %>
15 | <% for (var chunk of webpack.chunks) { %>
16 | <% if (chunk.names.length === 1 && chunk.names[0] === 'polyfills') continue; %>
17 | <% for (var file of chunk.files) { %>
18 | <% if (htmlWebpackPlugin.options.preload && file.match(/\.(js|css)$/)) { %>
19 |
20 | <% } else if (file.match(/manifest\.json$/)) { %>
21 |
22 | <% } %>
23 | <% } %>
24 | <% } %>
25 |
26 |
27 |
28 | <%= htmlWebpackPlugin.options.ssr({
29 | url: '/'
30 | }) %>
31 |
32 |
33 |
34 |
35 |
36 |
48 |
49 |
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/app/functions/auth/customClaim.onCreate.spec.js:
--------------------------------------------------------------------------------
1 | const { adminUtil, environmentUtil } = require('../utils');
2 | const environment = environmentUtil();
3 | const customClaimOnCreate = require('./customClaim.onCreate');
4 |
5 | describe('Custom Claim onCreate', () => {
6 | const uid = 'fake-uid';
7 | const user = {
8 | isTest: true,
9 | email: 'user@chrisesplin.com',
10 | };
11 | const adminUser = {
12 | isTest: true,
13 | isAdmin: true,
14 | email: 'admin@chrisesplin.com',
15 | };
16 | const admin = adminUtil(environment);
17 | const db = admin.firestore();
18 | const collection = db.collection(environment.collections.users);
19 | const query = collection.where('isTest', '==', true);
20 | const idTokenRefreshPath = environment.refs.idTokenRefresh.replace(/\{uid\}/, uid);
21 | const idTokenRefreshRef = admin.database().ref(idTokenRefreshPath);
22 | beforeAll(done => {
23 | deleteTestUsers()
24 | .then(() => collection.add(user))
25 | .then(() => collection.add(adminUser))
26 | .then(() => done(), done.fail);
27 | });
28 |
29 | afterAll(done => deleteTestUsers().then(() => done(), done.fail));
30 |
31 | function deleteTestUsers() {
32 | return query
33 | .get()
34 | .then(snapshot => {
35 | const batch = db.batch();
36 | snapshot.forEach(doc => batch.delete(doc.ref));
37 | return batch.commit();
38 | })
39 | .then(() => idTokenRefreshRef.remove());
40 | }
41 |
42 | const setCustomUserClaims = jest.fn(() => Promise.resolve(true));
43 | beforeAll(() => {
44 | // This works because adminUtil returns a singleton
45 | jest.spyOn(admin, 'auth').mockImplementation(() => ({ setCustomUserClaims }));
46 | });
47 |
48 | afterAll(() => admin.auth.mockRestore());
49 |
50 | let fn;
51 | beforeEach(() => {
52 | fn = customClaimOnCreate({ environment });
53 | });
54 |
55 | it('Initial state', done => {
56 | query.get().then(snapshot => {
57 | expect(snapshot.size).toEqual(2);
58 | done();
59 | });
60 | });
61 |
62 | describe('handles users based on isAdmin flag', () => {
63 | it('should reject a non-admin user', done => {
64 | fn({ data: { uid, email: 'user@chrisesplin.com' } }).then(result => {
65 | expect(result).toEqual(false);
66 | done();
67 | });
68 | });
69 |
70 | it('should set the admin claim for an admin user', done => {
71 | fn({ data: { uid, email: 'admin@chrisesplin.com' } }).then(result => {
72 | expect(result).toEqual(true);
73 | expect(setCustomUserClaims).toHaveBeenCalledWith(uid, { admin: true });
74 | done();
75 | });
76 | });
77 |
78 | it('should update idTokenRefresh timestamp', done => {
79 | const now = Date.now();
80 | fn({ data: { uid } })
81 | .then(() => idTokenRefreshRef.once('value'))
82 | .then(snapshot => {
83 | const value = snapshot.val();
84 | expect(value > now).toEqual(true);
85 | done();
86 | });
87 | });
88 | });
89 | });
90 |
--------------------------------------------------------------------------------
/app/functions/http/image.onRequest.spec.js:
--------------------------------------------------------------------------------
1 | const httpMocks = require('node-mocks-http');
2 | const imageOnRequest = require('./image.onRequest');
3 | const environment = require('../utils').environmentUtil();
4 | const { adminUtil, collectionsUtil } = require('../utils');
5 |
6 | describe('Image onRequest', () => {
7 | const md5Hash = 'MjQyNjY5M2NiNTYxM2M4MTkwZmY0YjNmZDdjM2E3NzI=';
8 | const record = `test-${md5Hash}`;
9 | const recordData = require(`../../data/${record}.json`);
10 |
11 | let fn;
12 | beforeEach(() => {
13 | fn = imageOnRequest({ environment });
14 | });
15 |
16 | let req;
17 | let res;
18 | beforeEach(() => {
19 | req = httpMocks.createRequest();
20 | res = httpMocks.createResponse();
21 | });
22 |
23 | let doc;
24 | beforeAll(done => {
25 | const admin = adminUtil(environment);
26 | const uploads = collectionsUtil(environment).get('uploads');
27 |
28 | doc = admin
29 | .firestore()
30 | .collection(uploads)
31 | .doc(record);
32 |
33 | doc.set(recordData).then(() => done(), done.fail);
34 | });
35 |
36 | it('should return a 404', done => {
37 | req.query = { record: 'not a valid record', width: 100 };
38 | fn(req, res)
39 | .then(done.fail)
40 | .catch(error => {
41 | expect(res.statusCode).toEqual(404);
42 | done();
43 | });
44 | });
45 |
46 | it('should return a 500', done => {
47 | req.query = { width: '1.1' };
48 | fn(req, res)
49 | .then(done.fail)
50 | .catch(error => {
51 | expect(res.statusCode).toEqual(500);
52 | done();
53 | });
54 | });
55 |
56 | describe('Versions', () => {
57 | it('should pipe an original file', done => {
58 | req.query = { record, environment: 'test' };
59 | fn(req, res).then(version => {
60 | expect(typeof version.url).toEqual('string');
61 | expect(res.statusCode).toEqual(200);
62 | done();
63 | });
64 | });
65 |
66 | it('should resize an image by width', done => {
67 | req.query = { record, environment: 'test', width: 50 };
68 | fn(req, res)
69 | .then(version => {
70 | expect(res.statusCode).toEqual(200);
71 | expect(typeof version.url).toEqual('string');
72 | done();
73 | })
74 | .catch(done.fail);
75 | });
76 |
77 | it('should resize an image by height', done => {
78 | req.query = { record, environment: 'test', height: 50 };
79 | fn(req, res)
80 | .then(version => {
81 | expect(res.statusCode).toEqual(200);
82 | expect(typeof version.url).toEqual('string');
83 | done();
84 | })
85 | .catch(done.fail);
86 | });
87 |
88 | it('should refuse to resize an image to a larger width', done => {
89 | req.query = { record, environment: 'test', width: 500 };
90 | fn(req, res)
91 | .then(version => {
92 | expect(res.statusCode).toEqual(200);
93 | expect(typeof version.url).toEqual('string');
94 | done();
95 | })
96 | .catch(done.fail);
97 | });
98 | });
99 | });
100 |
--------------------------------------------------------------------------------
/app/functions/storage/uploads.onChange.js:
--------------------------------------------------------------------------------
1 | const { adminUtil, collectionsUtil } = require('../utils');
2 | const exifParser = require('exif-parser');
3 |
4 | module.exports = ({ environment }) => async data => {
5 | const file = data;
6 | const { md5Hash: md5WithSlashes, name, resourceState } = file;
7 | const md5Hash = md5WithSlashes.replace(/[+/]/g, '|');
8 | const path = name.split('/');
9 |
10 | if (shouldSkip(path)) {
11 | return { skipped: true };
12 | } else {
13 | const admin = adminUtil(environment);
14 | const doc = getDoc({ admin, environment, md5Hash, path });
15 | const file = getFile(admin, name);
16 | let result;
17 |
18 | if (resourceState == 'not_exists') {
19 | result = await deleteFile(admin, doc);
20 | } else {
21 | const exif = await getExif(file);
22 | const payload = await getPayload(file, environment.env, exif, path);
23 |
24 | await doc.set(payload, { merge: true });
25 |
26 | result = payload;
27 | }
28 |
29 | return result;
30 | }
31 | };
32 |
33 | function getDoc({ admin, environment, md5Hash, path }) {
34 | const uploads = collectionsUtil(environment).get('uploads');
35 | const root = path[0];
36 | return admin
37 | .firestore()
38 | .collection(uploads)
39 | .doc(`${root}-${md5Hash}`);
40 | }
41 |
42 | function getFile(admin, name) {
43 | return admin
44 | .storage()
45 | .bucket()
46 | .file(name);
47 | }
48 |
49 | function shouldSkip(path) {
50 | return path.includes('test') || !path.includes('uploads');
51 | }
52 |
53 | function getExif(file) {
54 | return file.download({ start: 0, end: 1024 }).then(([buffer]) => {
55 | let exif = {};
56 | try {
57 | exif = exifParser.create(buffer).parse();
58 | } catch (e) {
59 | console.log('exif parsing error', e);
60 | }
61 | return exif;
62 | });
63 | }
64 | function getPayload(file, env, exif, path) {
65 | const environment = path[0];
66 | const created = Date.now();
67 | const filename = path[path.length - 1];
68 | const cleanedFile = {};
69 |
70 | for (const key in file) {
71 | const value = file[key];
72 | if (typeof file[key] == 'string') {
73 | cleanedFile[key] = value;
74 | }
75 | }
76 |
77 | const payload = {
78 | ...env,
79 | environment,
80 | created,
81 | filename,
82 | ...cleanedFile,
83 | metadata: file.metadata,
84 | };
85 |
86 | return mergeExif(payload, exif);
87 | }
88 |
89 | function mergeExif(payload, exif) {
90 | const { tags: exifTags } = exif;
91 | let merged = payload;
92 |
93 | if (exifTags && exifTags.CreateDate) {
94 | merged = Object.assign({ CreateDate: exifTags.CreateDate || Date.now(), exifTags }, payload);
95 | }
96 | return merged;
97 | }
98 |
99 | function deleteFile(admin, doc) {
100 | return doc
101 | .get()
102 | .then(doc => {
103 | const { versions } = doc.data();
104 | return Promise.all(
105 | Object.keys(versions || {})
106 | .map(key => versions[key])
107 | .map(version => {
108 | return admin
109 | .storage()
110 | .bucket()
111 | .file(version.name)
112 | .delete();
113 | })
114 | );
115 | })
116 | .catch(error => {
117 | console.error(error);
118 | return true;
119 | })
120 | .then(() => doc.delete());
121 | }
122 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # App
2 |
3 | All app code lives in `/app`.
4 |
5 | You can run all of the code without Docker... but it's so nice and clean to use Docker. So [install Docker](https://docs.docker.com/install/) and love your life just a little bit more.
6 |
7 | ### Environment Variables
8 |
9 | You'll need `FIREBASE_TOKEN` in your environment variables. Run `yarn ci:login` to generate the token. Then add it to `dev/workspace/env.list`. Look to `dev/workspace/env.list.dist` for the format.
10 |
11 | See the later section on _Vault_ to use the included [HashiCorp Vault](https://www.vaultproject.io/) implementation to secure your secrets.
12 |
13 | # VSCode
14 |
15 | This app is configured to run using [VSCode Containers](https://code.visualstudio.com/docs/remote/containers).
16 |
17 | Install [VSCode's Remote Development Extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.vscode-remote-extensionpack)
18 |
19 | The type `cmd + shift + p` and search for `Remote-Containers: Reopen Folder in Container`. This will open the project in a _Dockerized_ development environment.
20 |
21 | ### Like Docker but not VSCode?
22 |
23 | Try running `yarn dev` to boot up the _Dockerized_ workspace from the command line.
24 |
25 | # Vault
26 |
27 | This project is run using `docker-compose` to orchestrate the Docker containers.
28 |
29 | Vault is a fantastic way to secure secrets. It's massive overkill for this particular app... but it's a nice example of an enterprise-grade secrets implementation for front-end development.
30 |
31 | ### Service Account
32 |
33 | - Log into the [GCP IAM console](https://console.cloud.google.com/iam-admin/serviceaccounts?authuser=2&cloudshell=true&project=chris-esplin)
34 | - Create a service account with the `roles/storage.objectAdmin`, a.k.a. _Storage Object Admin_ permissions
35 | - Create a `json` key and download it.
36 | - Copy your `*.json` key to `./dev/vault/service-account.json`
37 |
38 | ### Environment Variables
39 |
40 | Copy `dev/vault/env.list.dist` and get rid of the `.dist` suffix. Fill in the values with whatever you generated from the vault.
41 |
42 | If you used more than one key, add them to `env.list` and edit `dev/vault/bin/unseal.sh` to provide the keys to the `vault operator unseal` function.
43 |
44 | ### GCP Back End
45 |
46 | Edit `./dev/vault/vault.config.json` and change the `gcs` bucket to a bucket that you own and that is controlled by your `service-account.json`.
47 |
48 | # Docker Compose
49 |
50 | ### Run All Servers
51 |
52 | - Run all servers with `docker-compose up`.
53 | - Run in daemon mode with `docker-compose up -d`.
54 | - Bring daemons down out with `docker-compose down`.
55 | - List running daemons with `docker-compose ps`.
56 |
57 | ### Run Vault
58 |
59 | - Connect to a running `vault` daemon with `docker exec -it vault sh`.
60 | - Watch daemon logs with `docker-compose logs -f vault`.
61 | - Get shell access to the `vault` container with `sh bin/interactive-vault.sh`.
62 | - Run just Vault with `sh ./bin/run-vault.sh`.
63 |
64 | ### Extract Secrets
65 |
66 | Run `sh bin/vault/copy-vault-keys.sh` or `powershell bin/vault/copy-vault-keys.ps1` to extract vault keys and expand secrets to separate files within `./app/vault/`.
67 |
68 | Do with these secrets files as you may.
69 |
70 | # Deploy
71 |
72 | You'll need to sort out your [Cloud Build triggers](https://console.cloud.google.com/cloud-build/builds).
73 |
74 | See below for a nice example trigger configuration. It's set up to look for pushes to a `prod` branch.
75 |
76 | Push to your `master` branch to `prod` with `git push origin master:prod`.
77 |
78 | 
79 |
--------------------------------------------------------------------------------
/app/src/components/images/images.scss:
--------------------------------------------------------------------------------
1 | .grid {
2 | display: flex;
3 | justify-content: space-between;
4 | width: calc(100vw - 3rem);
5 | flex-wrap: wrap;
6 | padding: 0;
7 | list-style: none;
8 |
9 | &[disable-scroll] {
10 | display: none;
11 | }
12 | }
13 |
14 | .item {
15 | display: flex;
16 | flex-direction: column;
17 | margin: 4px 0;
18 | position: relative;
19 | overflow: hidden;
20 | user-select: none;
21 |
22 | &:hover .description {
23 | background: black;
24 | display: block;
25 | }
26 |
27 | &:hover .actions {
28 | display: flex;
29 | }
30 |
31 | &:hover .tagList {
32 | display: flex;
33 | }
34 | }
35 |
36 | .description {
37 | background: rgba(25, 25, 25, 0.5);
38 | display: none;
39 | padding: 0.7rem 1rem 0.7rem 3rem;
40 | color: white;
41 | text-align: right;
42 | white-space: nowrap;
43 | overflow: hidden;
44 | text-overflow: ellipsis;
45 |
46 | position: absolute;
47 | top: 0;
48 | right: 0;
49 | left: 0;
50 |
51 | z-index: 2;
52 |
53 | em {
54 | color: var(--mdc-theme-primary);
55 | }
56 | }
57 |
58 | .tagList {
59 | position: absolute;
60 | right: 0;
61 | bottom: 0;
62 | left: 0;
63 | z-index: 1;
64 |
65 | display: none;
66 |
67 | list-style: none;
68 | padding: 1rem;
69 |
70 | li {
71 | background: rgba(25, 25, 25, 0.5);
72 | color: white;
73 | padding: 2px 5px;
74 | border-radius: 2px;
75 |
76 | em {
77 | color: var(--mdc-theme-primary);
78 | font-style: normal;
79 | }
80 | }
81 | }
82 |
83 | .actions {
84 | display: none;
85 | align-items: center;
86 | justify-content: space-evenly;
87 |
88 | background: rgba(0, 0, 0, 0.9);
89 |
90 | position: absolute;
91 | top: 0;
92 | right: 0;
93 | bottom: 0;
94 | left: 0;
95 |
96 | z-index: 1;
97 |
98 | img {
99 | cursor: pointer;
100 | width: 20%;
101 | height: 20%;
102 | border-radius: 2px;
103 | padding: 1rem;
104 |
105 | &:hover {
106 | background: black;
107 | }
108 | }
109 | }
110 |
111 | .image {
112 | flex-grow: 1;
113 | display: flex;
114 | justify-content: center;
115 | flex-direction: column;
116 | min-height: 200px;
117 | user-select: none;
118 |
119 | .img {
120 | height: 200px;
121 | background-position: center center;
122 | background-repeat: no-repeat;
123 | background-color: #fafafa;
124 | }
125 | }
126 |
127 | .icon {
128 | position: absolute;
129 | top: 0.5rem;
130 | left: 0.5rem;
131 |
132 | cursor: pointer;
133 | display: none;
134 | z-index: 3;
135 |
136 | fill: #ddd;
137 | }
138 |
139 | .markdown {
140 | position: absolute;
141 | user-select: text;
142 | width: 50px;
143 | z-index: -1;
144 | }
145 |
146 | .item[is-selected] .icon {
147 | display: inherit;
148 | fill: var(--mdc-theme-primary) !important;
149 | }
150 |
151 | .emptyState {
152 | font-size: 5rem;
153 | text-align: center;
154 | width: 100%;
155 | }
156 |
157 | .loadMore {
158 | padding: 3rem 1rem;
159 | margin: 1rem auto;
160 |
161 | img {
162 | display: block;
163 | margin: auto;
164 | }
165 | }
166 |
167 | .grid {
168 | .item:hover {
169 | .icon {
170 | display: inherit;
171 | }
172 | }
173 | }
174 |
175 | .grid[selecting] {
176 | .icon {
177 | display: inherit;
178 | background: none;
179 | border-color: #ddd;
180 | color: transparent;
181 | }
182 |
183 | .description {
184 | display: block;
185 | }
186 |
187 | .tagList {
188 | display: flex;
189 | }
190 | }
191 |
192 | .grid[is-admin] {
193 | .description {
194 | cursor: pointer;
195 | }
196 | }
197 |
--------------------------------------------------------------------------------
/app/README.md:
--------------------------------------------------------------------------------
1 | # fogo
2 |
3 | A live, working demo app to showcase Firebase's web features
4 |
5 | ## Install Node.js
6 |
7 | Use the [official Node.js install instructions](https://nodejs.org/en/download/) to get your
8 | Node on your system. Once you can run `node --version` in your command line, you're good to go.
9 |
10 | I recommend the latest LTS version of Node.js. Even-numbered version of Node are the long-term
11 | support (LTS) versions. The Odd-numbered versions are the bleeding edge. I'm using v8.9.1 as of
12 | this writing. I'll stay on the 8.x branch until the 10.x branch ships.
13 |
14 | I use [nvs](https://github.com/jasongin/nvs) and `.node-version` files to manage Node versions.
15 | This matters because the tests run in `/functions` need to be run in the version of Node that
16 | Cloud Functions uses, and that version has historically lagged the most recent LTS version.
17 | See `/functions/.node-version`.
18 |
19 | ## Install your package manager of choice
20 |
21 | You can use either [Yarn](https://yarnpkg.com/lang/en/docs/install/) or
22 | [NPM](https://www.npmjs.com/get-npm). They should both work; however, I'm using yarn as of this
23 | writing, because I'm a hipster like that.
24 |
25 | > Run a quick check to make sure you have everything before moving on.
26 |
27 | ```bash
28 | yarn --version #should read out a version number
29 | ```
30 |
31 | ## Clone the repo
32 |
33 | Open your command line and `cd` to your favorite development directory. Then clone the repo:
34 |
35 | ```bash
36 | git clone https://github.com/how-to-firebase/fogo.git
37 | cd fogo
38 | ```
39 |
40 | ## Install dependencies
41 |
42 | Node.js packages install their dependencies into `/node_modules`. This can be done with
43 | Yarn or NPM.
44 |
45 | ```bash
46 | # Using Yarn
47 | yarn
48 | ```
49 |
50 | ```bash
51 | # Using NPM
52 | npm install
53 | ```
54 |
55 | ## Edit environment files
56 |
57 | There are two "dist" environment files, `/src/environment.js.dist` and
58 | `/functions/config.json.dist`.
59 |
60 | Make sure to copy each dist file without the `.dist` at the end, and edit it to match your
61 | development and deploy environments. For instance, you'll need to modify the Firebase details to
62 | point to your own target Firebase instance. You'll also need an Algolia.com account with the
63 | appropriate API keys, or you won't get any search.
64 |
65 | Notice that `/src/environment.js.dist` has a `howtofirebase` environment. This is because I like to
66 | host multiple sites on a single Firebase instance. I built this app to run dynamically in different
67 | "environments" based on `location.hostname`. So you can make up as many environments as you like in
68 | `/src/environment.js` and assign them to hostnames as necessary. Mix and match. It's fun!
69 |
70 | ## Serve it up locally
71 |
72 | This project uses package scripts from `/package.json`.
73 |
74 | [Read the docs](https://yarnpkg.com/lang/en/docs/cli/run/) if you're fuzzy on package scripts.
75 |
76 | Run `yarn start` and you should see something like this:
77 |
78 | ```
79 | Compiled successfully!
80 |
81 | You can view the application in browser.
82 |
83 | Local: http://localhost:8080
84 | On Your Network: http://192.168.1.25:8080
85 | ```
86 |
87 | Now open the application up in your browser and you're live!
88 |
89 | ## Deploy
90 |
91 | You can deploy to your own Firebase project by installing the Firebase CLI
92 | with `yarn global add firebase-tools` and running `firebase init`. Once Firebase is initialized to
93 | your own project you'll notice a new file--`/.firebaserc`--that should point to your project.
94 |
95 | Run `yarn deploy` to deploy the whole app, or see the scripts listed in `/package.json` for more
96 | options.
97 |
98 | ## Questions? Bugs?
99 |
100 | This app needs to be immaculate. Bulletproof. Perfect.
101 |
102 | Please file issues for questions, bug reports... anything.
103 |
104 | If in doubt, file an issue and I'll get right on it!
105 |
--------------------------------------------------------------------------------
/app/src/components/views/gallery.view.js:
--------------------------------------------------------------------------------
1 | import style from './gallery.view.scss';
2 | import { imagesByTagQuery, imageVersionQuery } from '../../queries';
3 |
4 | const spinner = '../../assets/svg/spinner.svg';
5 |
6 | let increment;
7 | let setWindowFocus;
8 | window.addEventListener('keyup', ({ key }) => {
9 | if (increment) {
10 | if (key == 'ArrowRight') {
11 | increment(1);
12 | } else if (key == 'ArrowLeft') {
13 | increment(-1);
14 | }
15 | }
16 | });
17 | window.addEventListener('focus', e => setWindowFocus && setWindowFocus(true));
18 | window.addEventListener('blur', e => setWindowFocus && setWindowFocus(false));
19 |
20 | export function GalleryView({ environment }) {
21 | const pathParts = location.pathname.split('/').slice(1);
22 | const [galleryName, tag] = pathParts;
23 | const { focused, images, index } = this.state;
24 | const length = (images && images.length) || 0;
25 | const i = index || 0;
26 | const image = images && images[i];
27 | const setImages = images => {
28 | this.setState({ images });
29 | saveToLocalStorage({ images, tag });
30 | };
31 | const setImage = image => {
32 | const id = image.__id;
33 | const index = images.findIndex(image => image.__id == id);
34 | images[index] = image;
35 | setImages(images);
36 | };
37 | const setIndex = nextI => {
38 | const index = (nextI == -1 && length - 1) || nextI % length;
39 | this.setState({ index });
40 | };
41 | const changeImage = next => e => increment((next && 1) || -1);
42 |
43 | increment = change => setIndex(i + change);
44 |
45 | setWindowFocus = focused => this.setState({ focused });
46 |
47 | getImages({ environment, images, tag, setImages });
48 |
49 | ensureOriginal({ environment, image, setImage });
50 |
51 | return (
52 |
53 | {image && (
54 |
55 |
60 |
61 | )}
62 |
63 |
64 | {image && [
65 |
{image.description}
,
66 |
← →,
67 |
,
70 |

,
76 |

,
82 | ]}
83 |
84 |
85 | );
86 | }
87 |
88 | // Manage state
89 | const LOCALSTORAGE_NAME = 'fogo-gallery';
90 | const savedString = window.localStorage.getItem(LOCALSTORAGE_NAME);
91 | const saved = JSON.parse(savedString);
92 | const now = Date.now();
93 |
94 | function getImages({ environment, images, tag, setImages }) {
95 | if (!images) {
96 | if (isSavedValid({ now, saved, tag })) {
97 | setImages(saved.images);
98 | } else {
99 | imagesByTagQuery({ environment, tag }).then(images => {
100 | setImages(images);
101 | });
102 | }
103 | }
104 | }
105 |
106 | function isSavedValid({ now, saved, tag }) {
107 | const tenMinutes = 1000 * 60 * 10;
108 | return !!saved && saved.tag == tag && saved.timestamp + tenMinutes > now;
109 | }
110 |
111 | function saveToLocalStorage({ images, tag }) {
112 | const payload = {
113 | images,
114 | tag,
115 | timestamp: Date.now(),
116 | };
117 | localStorage.setItem(LOCALSTORAGE_NAME, JSON.stringify(payload));
118 | }
119 |
120 | function ensureOriginal({ environment, image, setImage }) {
121 | if (image && (!image.versions || !image.versions.original)) {
122 | imageVersionQuery({ environment, record: image.__id, versionName: 'original' }).then(url => {
123 | image.versions = {
124 | original: {
125 | url,
126 | },
127 | };
128 | setImage(image);
129 | });
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/app/src/components/image-detail/imageDetail.component.js:
--------------------------------------------------------------------------------
1 | import style from './imageDetail.scss';
2 | import { connect } from 'unistore';
3 | import { updateImageQuery } from '../../queries';
4 | import { mappedActions } from '../../datastore';
5 | const { setImage } = mappedActions;
6 |
7 | // SVG
8 | import spinner from '../../assets/svg/spinner.svg';
9 | import contentCopy from '../../assets/svg/content-copy.svg';
10 |
11 | export default function imageDetail({ environment, image, isAdmin }) {
12 | const version = getVersion(image);
13 | const versionRows = image && getVersionRows(image);
14 |
15 | loadImageIfMissing(image);
16 |
17 | const setDescriptionState = descriptionState => {
18 | this.setState({ descriptionState });
19 | };
20 |
21 | return (
22 | image && (
23 |
24 |

25 |

30 |
31 | - {image.name.split('/').pop()}
32 | {image.exifTags && (
33 | -
34 | {image.contentType} @ {image.exifTags.ExifImageHeight}x{image.exifTags.ExifImageWidth}
35 |
36 | )}
37 | {image.exifTags && - {new Date(image.exifTags.CreateDate).toString()}
}
38 | {versionRows}
39 | {isAdmin && (
40 |
41 |
48 |
49 |
50 | )}
51 |
52 |
53 | )
54 | );
55 | }
56 |
57 | function loadImageIfMissing(image) {
58 | if (image && !getVersion(image)) {
59 | setImage({ ...image, versions: { original: 'loading' } });
60 | }
61 | }
62 |
63 | function getVersion(image) {
64 | const versionName = 'original';
65 | const { versions } = image || {};
66 | return versions && versions[versionName];
67 | }
68 |
69 | function getVersionRows({ exifTags, versions }) {
70 | return Object.keys(versions).map(key => {
71 | const version = versions[key];
72 | if (version != 'loading') {
73 | const dimensions = getDimensions({ exifTags, key });
74 | const url = getUrl(version);
75 | const markdown = getMarkdown(version);
76 |
77 | return [
78 |
79 |
80 | {dimensions}
81 | {url}
82 | ,
83 |
84 |
85 |
86 | {markdown}
87 | ,
88 | ];
89 | }
90 | });
91 | }
92 |
93 | function getDimensions({ exifTags, key }) {
94 | const { ExifImageHeight, ExifImageWidth } = exifTags || {};
95 | return (key == 'original' && exifTags && `${ExifImageWidth}x${ExifImageHeight}`) || key;
96 | }
97 |
98 | function getMarkdown(version) {
99 | const filename = getFilename(version);
100 | const url = getUrl(version);
101 | return ``;
102 | }
103 |
104 | function getFilename(version) {
105 | return version.name.split('/').pop();
106 | }
107 |
108 | function getUrl(version) {
109 | return version.shortUrl || version.url;
110 | }
111 |
112 | function handleOverlayClick(e) {
113 | if (e.target.hasAttribute('close')) {
114 | setImage();
115 | }
116 | }
117 |
118 | function handleCopyClick({ target }) {
119 | const selection = window.getSelection();
120 | const range = document.createRange();
121 | range.selectNodeContents(target.parentElement.children[2]);
122 | selection.removeAllRanges();
123 | selection.addRange(range);
124 | document.execCommand('copy');
125 |
126 | fireAlert('Copied to clipboard');
127 | }
128 |
129 | function fireAlert(detail) {
130 | dispatchEvent(new CustomEvent('alert', { detail, bubbles: true }));
131 | }
132 |
133 | let inputTimer;
134 | function handleInput({ environment, image, setDescriptionState }) {
135 | return ({ target }) => {
136 | setDescriptionState('saving...');
137 | if (inputTimer) {
138 | clearTimeout(inputTimer);
139 | }
140 | inputTimer = setTimeout(async () => {
141 | const description = target.value;
142 | await updateImageQuery({ environment, image: { ...image, description } });
143 | setDescriptionState('saved');
144 | }, 2000);
145 | };
146 | }
147 |
--------------------------------------------------------------------------------
/app/src/index.js:
--------------------------------------------------------------------------------
1 | import './style';
2 | import { Component } from 'preact';
3 | import Router from 'preact-router';
4 | import { Provider } from 'unistore';
5 | import { store, mappedActions } from './datastore';
6 | import Match from 'preact-router/match';
7 |
8 | const { route } = Router;
9 |
10 | const { setCurrentUser, setPath, setToken } = mappedActions;
11 |
12 | // Quiver
13 | import FirebaseAuthentication from '@quiver/firebase-authentication';
14 | import StorageUploader from '@quiver/storage-uploader';
15 |
16 | // Dependencies
17 | import Nav from './components/nav/nav.component';
18 | import Drawer from './components/drawer/drawer.component';
19 | import Guard from './components/guard/guard.component';
20 |
21 | // Preact Material Components
22 | import Snackbar from 'preact-material-components/Snackbar';
23 | import 'preact-material-components/Snackbar/style.css';
24 |
25 | // Views
26 | import { EmbedView, GalleriesView, GalleryView, ImagesView, TagsView } from './components/views';
27 |
28 | const pathParts = new Set(location.pathname.split('/'));
29 | const isGallery = pathParts.has('gallery');
30 |
31 | export default class Fogo extends Component {
32 | componentWillMount() {
33 | if (!isGallery) {
34 | registerOnAuthStateChanged();
35 |
36 | addEventListener('alert', e =>
37 | this.snackbar.MDComponent.show({
38 | message: e.detail,
39 | timeout: 1000,
40 | })
41 | );
42 | }
43 | }
44 |
45 | componentDidMount() {
46 | if (!isGallery) {
47 | registerStorageUploaderListeners();
48 | registerIdTokenRefreshListener();
49 | }
50 | }
51 |
52 | render() {
53 | const { environment } = store.getState();
54 |
55 | return (
56 |
57 |
58 | {(isGallery &&
) || [
59 |
{handlePath},
60 |
,
61 |
(this.snackbar = snackbar)} style="z-index: 1000;" />,
62 | ,
63 | ,
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
76 | 404
77 |
78 |
,
79 | ]}
80 |
81 |
82 | );
83 | }
84 | }
85 |
86 | function registerOnAuthStateChanged() {
87 | window.firebase.auth().onAuthStateChanged(setCurrentUser);
88 | window.firebase.auth().onAuthStateChanged(currentUser => {
89 | console.info('onAuthStateChanged currentUser:', currentUser);
90 | });
91 | }
92 |
93 | function registerStorageUploaderListeners() {
94 | addEventListener('storageUploaderComplete', e => route('/'));
95 |
96 | addEventListener('storageUploaderError', e =>
97 | dispatchEvent(new CustomEvent('alert', { detail: e.detail.error.message }))
98 | );
99 | }
100 |
101 | function registerIdTokenRefreshListener() {
102 | let idTokenRefreshRef;
103 | let handler;
104 | store.subscribe(({ environment, token, laggedCurrentUser, currentUser }) => {
105 | if (handler && laggedCurrentUser && currentUser && laggedCurrentUser.uid != currentUser.uid) {
106 | idTokenRefreshRef.off('value', handler);
107 | handler = null;
108 | } else if (!handler && environment && currentUser && currentUser.uid) {
109 | idTokenRefreshRef = getIdTokenRefreshRef({ environment, uid: currentUser.uid });
110 | handler = idTokenRefreshRef.on('value', snapshot => {
111 | getToken({ currentUser, force: true }).then(setToken);
112 | });
113 | }
114 | });
115 | }
116 |
117 | function getIdTokenRefreshRef({ environment, uid }) {
118 | const path = environment.refs.idTokenRefresh.replace(/\{uid\}/, uid);
119 | return window.firebase.database().ref(path);
120 | }
121 |
122 | function getToken({ currentUser, force }) {
123 | //https://firebase.google.com/docs/auth/admin/custom-claims
124 | return currentUser
125 | .getIdToken(force)
126 | .then(idToken => JSON.parse(b64DecodeUnicode(idToken.split('.')[1])));
127 | }
128 |
129 | function b64DecodeUnicode(str) {
130 | // https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding
131 | // Going backwards: from bytestream, to percent-encoding, to original string.
132 | return decodeURIComponent(
133 | atob(str)
134 | .split('')
135 | .map(function(c) {
136 | return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
137 | })
138 | .join('')
139 | );
140 | }
141 |
142 | function handlePath({ path }) {
143 | setPath(path);
144 | document.body.parentElement.scrollTop = 0;
145 | }
146 |
--------------------------------------------------------------------------------
/app/functions/http/image.onRequest.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const { exec } = require('child_process');
3 | const { adminUtil, collectionsUtil } = require('../utils');
4 |
5 | module.exports = ({ environment }) => async (req, res) => {
6 | const error = getError(req, res);
7 | if (error) {
8 | return handleError(res, 500, error);
9 | } else {
10 | const { record, width, height } = req.query;
11 | const { admin, uploads } = getEnvironmentDependencies(environment);
12 | const docRef = getDoc(admin, uploads, record);
13 |
14 | try {
15 | const doc = await docRef.get();
16 |
17 | if (!doc.exists) {
18 | return handleError(res, 404, 'Not Found');
19 | } else {
20 | const version = getVersion(doc, { width, height });
21 | const admin = adminUtil(environment);
22 | const newVersion = version || (await createNewVersion(admin, doc, { width, height }));
23 |
24 | res.status(200);
25 | res.send(newVersion.url);
26 | return newVersion;
27 | }
28 | } catch (error) {
29 | return handleError(res, 500, error);
30 | }
31 | }
32 | };
33 |
34 | function getError(req) {
35 | const { width, height } = req.query;
36 | let error;
37 |
38 | if ((width && !isInt(width)) || (height && !isInt(height))) {
39 | error = `width or height must be an integer: ${JSON.stringify({ width, height })}`;
40 | }
41 | return error;
42 | }
43 |
44 | function isInt(s) {
45 | const int = parseInt(s);
46 | return s && String(int) == s;
47 | }
48 |
49 | function handleError(res, type, error) {
50 | res.status(type);
51 | res.send(error);
52 | return Promise.reject(error);
53 | }
54 |
55 | function getEnvironmentDependencies(environment) {
56 | const admin = adminUtil(environment);
57 | const uploads = collectionsUtil(environment).get('uploads');
58 |
59 | return { admin, uploads };
60 | }
61 |
62 | function getDoc(admin, uploads, record) {
63 | return admin
64 | .firestore()
65 | .collection(uploads)
66 | .doc(record);
67 | }
68 |
69 | function getVersion(doc, { width, height }) {
70 | const data = doc.data();
71 | const versions = data.versions || {};
72 | const versionName = getVersionName({ width, height });
73 | return versions && versions[versionName];
74 | }
75 |
76 | function createNewVersion(admin, doc, { width, height }) {
77 | const versionName = getVersionName({ width, height });
78 | const filename = getFilename(doc);
79 | const file = getFile(admin, filename);
80 |
81 | return Promise.resolve()
82 | .then(() => {
83 | if (versionName == 'original') {
84 | return file;
85 | } else {
86 | return convertFile(admin, file, versionName);
87 | }
88 | })
89 | .then(file => getSignedUrl(file).then(url => ({ url, name: file.name })))
90 | .then(version => saveDoc(doc, versionName, version));
91 | }
92 |
93 | function getVersionName({ width, height }) {
94 | return width || (height && `x${height}`) || 'original';
95 | }
96 |
97 | function getFilename(doc) {
98 | const { name } = doc.data();
99 | return name;
100 | }
101 |
102 | function convertFile(admin, file, versionName) {
103 | const filename = file.name;
104 | const localFilename = getLocalFilename(filename);
105 |
106 | return file
107 | .download({ destination: localFilename })
108 | .then(() => convertLocalFile(localFilename, versionName))
109 | .then(() => {
110 | const destination = getDestination(filename, versionName);
111 | const newFile = getFile(admin, destination);
112 |
113 | return newFile.bucket
114 | .upload(localFilename, {
115 | destination,
116 | metadata: {
117 | cacheControl: 'public, max-age=31536000',
118 | },
119 | })
120 | .then(() => unlinkPromise(localFilename))
121 | .then(() => newFile);
122 | });
123 | }
124 |
125 | function getLocalFilename(filename) {
126 | return `/tmp/${Date.now()}`;
127 | }
128 |
129 | function convertLocalFile(localFilename, versionName) {
130 | const cmd = getCmd(localFilename, versionName);
131 | return execPromise(cmd);
132 | }
133 |
134 | function getCmd(localFilename, versionName) {
135 | return `convert ${localFilename} -resize ${versionName}\\> ${localFilename}`;
136 | }
137 |
138 | function execPromise(cmd) {
139 | return new Promise((resolve, reject) =>
140 | exec(cmd, (error, stdout) => (error ? reject(error) : resolve(stdout)))
141 | );
142 | }
143 |
144 | function getDestination(filename, versionName) {
145 | const filenameParts = filename.split('/');
146 | filenameParts[filenameParts.length - 2] = versionName;
147 | return filenameParts.join('/');
148 | }
149 |
150 | function getFile(admin, filename) {
151 | return admin
152 | .storage()
153 | .bucket()
154 | .file(filename);
155 | }
156 |
157 | function unlinkPromise(localFilename) {
158 | return new Promise(
159 | (resolve, reject) => fs.unlink(localFilename, err => err && reject(err)) || resolve()
160 | );
161 | }
162 |
163 | function getSignedUrl(file) {
164 | return file
165 | .getSignedUrl({ action: 'read', expires: `01-01-${new Date().getFullYear() + 20}` })
166 | .then(([url]) => url);
167 | }
168 |
169 | function saveDoc(doc, versionName, version) {
170 | let { versions } = doc.data();
171 | if (!versions) {
172 | versions = {};
173 | }
174 | versions[versionName] = version;
175 | return doc.ref.update({ versions }).then(() => version);
176 | }
177 |
--------------------------------------------------------------------------------
/app/src/components/tags/tags.component.js:
--------------------------------------------------------------------------------
1 | import style from './tags.scss';
2 | import linkState from 'linkstate';
3 | import { Component } from 'preact';
4 | import { route } from 'preact-router';
5 | import { connect } from 'unistore';
6 | import { store, actions, mappedActions } from '../../datastore';
7 | import { updateTagsQuery } from '../../queries';
8 |
9 | const { updateImage } = mappedActions;
10 |
11 | // Preact Material
12 | import TextField from 'preact-material-components/TextField';
13 | import Button from 'preact-material-components/Button';
14 | import 'preact-material-components/TextField/style.css';
15 | import 'preact-material-components/Button/style.css';
16 |
17 | // Svg
18 | const spinner = '/assets/svg/spinner.svg';
19 |
20 | export default connect('environment,images,searchResults,selection', actions)(
21 | ({ environment, images, searchResults, selection }) => {
22 | if (!selection.size) {
23 | setTimeout(() => {
24 | route('/images');
25 | }, 1000);
26 | } else {
27 | const selectedImages = getSelectedImages({ images, searchResults, selection });
28 | const items = getItems({ environment, selectedImages });
29 | const globalTagItems = getGlobalTagItems({ environment, selectedImages });
30 | return (
31 |
32 |
41 |
42 | {!!globalTagItems.length && (
43 |
44 |
All Tags
45 |
46 |
47 |
48 | )}
49 |
50 |
51 |
52 | );
53 | }
54 | }
55 | );
56 |
57 | // Extract data
58 | function getSelectedImages({ images, searchResults, selection }) {
59 | let result;
60 | if (searchResults) {
61 | result = searchResults.hits.filter(hit => selection.has(hit.objectID));
62 | } else {
63 | result = images.filter(image => selection.has(image.__id));
64 | }
65 | return result;
66 | }
67 |
68 | function getItems({ environment, selectedImages }) {
69 | return selectedImages.map(image => {
70 | const version = (image.versions && image.versions.x200) || { url: spinner };
71 | const tagItems = image.tags && image.tags.map(getTagItem({ environment, image }));
72 |
73 | return (
74 |
75 |
76 |
77 |
{image.filename}
78 |
79 |
80 |
81 | );
82 | });
83 | }
84 |
85 | function getGlobalTagItems({ environment, selectedImages }) {
86 | const tags = getTags(selectedImages);
87 | return Array.from(tags).map(getGlobalTagItem({ environment, selectedImages }));
88 | }
89 |
90 | function getTags(selectedImages) {
91 | return selectedImages.reduce((tags, image) => {
92 | if (image.tags) {
93 | image.tags.forEach(tag => tags.add(tag));
94 | }
95 | return tags;
96 | }, new Set());
97 | }
98 |
99 | function getGlobalTagItem({ environment, selectedImages }) {
100 | return tag => {
101 | return (
102 |
103 |
108 | #{tag}
109 |
110 | );
111 | };
112 | }
113 |
114 | function getTagItem({ environment, image }) {
115 | return tag => {
116 | return (
117 |
118 |
123 | #{tag}
124 |
125 | );
126 | };
127 | }
128 |
129 | // Handle events
130 | function handleSubmit({ environment, selectedImages }) {
131 | return e => {
132 | e.preventDefault();
133 |
134 | const input = e.target.querySelector('input');
135 | let hashtag = input.value;
136 | hashtag = hashtag.toLowerCase();
137 | hashtag = hashtag.replace(/[^a-z,0-9]/g, '');
138 |
139 | input.value = '';
140 | input.focus();
141 |
142 | selectedImages
143 | .map(image => ({ image, tags: new Set(image.tags || []) }))
144 | .forEach(({ image, tags }) => {
145 | tags.add(hashtag);
146 | updateTags({ environment, image, tags });
147 | });
148 | };
149 | }
150 |
151 | function handleTagClick({ environment, image, tag }) {
152 | return e => {
153 | const tags = new Set(image.tags || []);
154 | tags.delete(tag);
155 | updateTags({ environment, image, tags });
156 | };
157 | }
158 |
159 | function handleGlobalTagClick({ environment, selectedImages, tag }) {
160 | return e => {
161 | selectedImages
162 | .map(image => ({ image, tags: new Set(image.tags || []) }))
163 | .filter(({ tags }) => tags.has(tag))
164 | .forEach(({ image, tags }) => {
165 | tags.delete(tag);
166 | updateTags({ environment, image, tags });
167 | });
168 | };
169 | }
170 |
171 | async function updateTags({ environment, image, tags }) {
172 | const { __id: id } = image;
173 | optimisticUpdate({ image, tags });
174 | await updateTagsQuery({ environment, id, tags });
175 | bustSearchCache();
176 | }
177 |
178 | function optimisticUpdate({ image, tags }) {
179 | image.tags = Array.from(tags);
180 | updateImage(image);
181 | }
182 |
183 | function bustSearchCache() {
184 | window.dispatchEvent(new CustomEvent('search-cache-bust'));
185 | }
186 |
--------------------------------------------------------------------------------
/app/src/assets/algolia.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/src/components/images/images.component.js:
--------------------------------------------------------------------------------
1 | import { Component } from 'preact';
2 | import style from './images.scss';
3 | import { connect } from 'unistore';
4 | import { store, actions, mappedActions } from '../../datastore';
5 | import { imagesObserver } from '../../observers';
6 | import { imagesQuery, loadImageVersionIfNecessary } from '../../queries';
7 |
8 | const {
9 | addImage,
10 | addImages,
11 | addSelection,
12 | clearSelection,
13 | removeSelection,
14 | setImage,
15 | setImagesAllLoaded,
16 | setImagesWidth,
17 | setSelecting,
18 | } = mappedActions;
19 |
20 | // Svg
21 | const spinner = '/assets/svg/spinner.svg';
22 | const threeDots = '/assets/svg/three-dots.svg';
23 | const contentCopy = '/assets/svg/content-copy.svg';
24 | const openWith = '/assets/svg/open-with.svg';
25 |
26 | // Components
27 | import ImageDetail from '../image-detail/imageDetail.component';
28 |
29 | // constants
30 | const GUTTER = 4;
31 | const HEIGHT = 200;
32 | const DEFAULT_WIDTH = 200;
33 | const VERSION_NAME = `x${HEIGHT}`;
34 | const MARKDOWN_VERSION_NAME = '640';
35 |
36 | @connect(
37 | ({
38 | environment,
39 | images,
40 | imagesAllLoaded,
41 | imagesWidth,
42 | image,
43 | isAdmin,
44 | search,
45 | searching,
46 | searchResults,
47 | selecting,
48 | selection,
49 | timestamp,
50 | }) => ({
51 | environment,
52 | images,
53 | imagesAllLoaded,
54 | imagesWidth,
55 | image,
56 | isAdmin,
57 | search,
58 | searching,
59 | searchResults,
60 | selecting,
61 | selection,
62 | timestamp,
63 | })
64 | )
65 | export default class Images extends Component {
66 | componentWillMount() {
67 | this.__evaluateLoadingPosition = getEvaluateLoadingPosition(this.props);
68 | window.document.addEventListener('scroll', this.__evaluateLoadingPosition);
69 | window.document.addEventListener('keyup', handleKeyup);
70 |
71 | const { environment } = this.props;
72 | const lastCreated = getLastCreated(store.getState());
73 | this.__imagesSubscription = imagesObserver({ environment, lastCreated }).subscribe(
74 | handleNewImages({ environment, store })
75 | );
76 | }
77 |
78 | componentDidMount() {
79 | this.handleResize();
80 | this.__handleResize = this.handleResize.bind(this);
81 | addEventListener('resize', this.__handleResize);
82 | }
83 |
84 | componentWillUnmount() {
85 | window.document.removeEventListener('scroll', this.__evaluateLoadingPosition);
86 | window.document.removeEventListener('keyup', handleKeyup);
87 | removeEventListener('resize', this.__handleResize);
88 | this.__imagesSubscription.unsubscribe();
89 | }
90 |
91 | componentDidUpdate() {
92 | this.__evaluateLoadingPosition();
93 | }
94 |
95 | handleResize() {
96 | setImagesWidth(this.base.offsetWidth);
97 | }
98 |
99 | render({
100 | environment,
101 | images,
102 | imagesAllLoaded,
103 | imagesWidth,
104 | image,
105 | isAdmin,
106 | search,
107 | searching,
108 | searchResults,
109 | selecting,
110 | selection,
111 | }) {
112 | const base = this.base;
113 | const imageDetailClick = getImageDetailClickHandler({
114 | base,
115 | environment,
116 | images,
117 | selection,
118 | });
119 | const selectClick = getSelectClickHandler({
120 | base,
121 | isAdmin,
122 | selection,
123 | });
124 | const copyClick = getCopyClickHandler();
125 |
126 | const decoratedImages = getDecoratedImages({ searching, search, searchResults, images });
127 | const items = justifyWidths({ images: decoratedImages, gutter: GUTTER, imagesWidth }).map(
128 | image =>
129 | getImageRow({
130 | environment,
131 | image,
132 | isAdmin,
133 | selection,
134 | copyClick,
135 | imageDetailClick,
136 | selectClick,
137 | })
138 | );
139 |
140 | return (
141 |
142 |
setSelecting(false)}
147 | />
148 |
149 | {items}
150 | {imagesAllLoaded &&
151 | items.length == 1 && - Nothing to show 😪
}
152 |
153 |
158 |

159 |
160 |
161 | );
162 | }
163 | }
164 |
165 | // Lifecycle functions
166 | function getEvaluateLoadingPosition({ environment, pageSize }) {
167 | return debounce(() => {
168 | const { images, imagesAllLoaded, searching } = store.getState();
169 |
170 | if (!imagesAllLoaded && !searching) {
171 | evaluateLoadingPosition({ pageSize, environment, images });
172 | }
173 | }, 500);
174 | }
175 |
176 | function debounce(fn, millis) {
177 | let timer;
178 |
179 | return () => {
180 | if (timer) {
181 | clearTimeout(timer);
182 | }
183 | timer = setTimeout(fn, millis);
184 | };
185 | }
186 |
187 | async function evaluateLoadingPosition({ pageSize: limit, environment, images }) {
188 | const loadingBar = window.document.getElementById('loading-bar');
189 | const scroll = window.document.body.parentElement.scrollTop;
190 | const top = loadingBar.getBoundingClientRect().top;
191 | const viewportHeight = window.innerHeight;
192 |
193 | let imagesToLoad = images;
194 | if (top < viewportHeight) {
195 | const cursor = images[images.length - 1];
196 | const { results, imagesAllLoaded } = await imagesQuery({ cursor, environment, images, limit });
197 | addImages(results);
198 | setImagesAllLoaded(imagesAllLoaded);
199 | imagesToLoad = results;
200 | }
201 |
202 | imagesToLoad.forEach(image =>
203 | loadImageVersionIfNecessary({ environment, image, versionName: VERSION_NAME })
204 | );
205 | }
206 |
207 | function getLastCreated({ images, timestamp }) {
208 | return (
209 | (images.length &&
210 | images.reduce((timestamp, image) => {
211 | return Math.max(timestamp, image.created);
212 | }, 0)) ||
213 | timestamp
214 | );
215 | }
216 |
217 | function handleNewImages({ environment, store }) {
218 | return newImages => {
219 | const { images } = store.getState();
220 | const existingIds = new Set(images.map(x => x.__id));
221 | const differenceIds = new Set(newImages.map(x => x.__id).filter(id => !existingIds.has(id)));
222 | const imagesToAdd = newImages.filter(image => differenceIds.has(image.__id));
223 |
224 | imagesToAdd.forEach(image => {
225 | addImage(image);
226 | loadImageVersionIfNecessary({ environment, image, versionName: VERSION_NAME });
227 | });
228 | };
229 | }
230 |
231 | function handleKeyup({ key }) {
232 | if (key == 'Escape') {
233 | setSelecting(false);
234 | clearSelection();
235 | setImage();
236 | }
237 | }
238 |
239 | // Render event handlers
240 | function getSelectClickHandler({ base, isAdmin, selection }) {
241 | return e => {
242 | e.stopPropagation();
243 | if (isAdmin) {
244 | const id = getId(e.target);
245 | const isSelected = selection.has(id);
246 | if (!isSelected) {
247 | if (e.shiftKey) {
248 | multiSelect({ id, base, addSelection });
249 | } else {
250 | addSelection(id);
251 | }
252 | } else {
253 | if (selection.size <= 1) {
254 | setSelecting(false);
255 | }
256 | removeSelection(id);
257 | }
258 | }
259 | };
260 | }
261 |
262 | function getImageDetailClickHandler({ base, environment, images, selection }) {
263 | return e => {
264 | const id = getId(e.target);
265 | const isSelected = selection.has(id);
266 |
267 | if (e.ctrlKey) {
268 | if (!isSelected) {
269 | addSelection(id);
270 | } else {
271 | removeSelection(id);
272 | }
273 | } else if (e.shiftKey) {
274 | multiSelect({ id, base, addSelection });
275 | } else {
276 | const image = images.find(image => image.__id == id);
277 | setImage(image);
278 | loadImageVersionIfNecessary({ environment, image });
279 | }
280 | };
281 | }
282 |
283 | function getId(el) {
284 | const itemId = el.getAttribute('item-id');
285 | return itemId || (el.parentElement && getId(el.parentElement));
286 | }
287 |
288 | function multiSelect({ id, base, addSelection }) {
289 | const items = Array.from(base.querySelectorAll('li'));
290 | const firstSelectedItemIndex = items.findIndex(item => item.getAttribute('is-selected'));
291 | const clickedItemIndex = items.findIndex(item => item.getAttribute('item-id') == id);
292 | const startIndex = Math.min(firstSelectedItemIndex, clickedItemIndex);
293 | const endIndex = Math.max(firstSelectedItemIndex, clickedItemIndex);
294 | const ids = items.slice(startIndex, endIndex + 1).map(item => item.getAttribute('item-id'));
295 | addSelection((ids.length && ids) || id);
296 | }
297 |
298 | function getCopyClickHandler() {
299 | return e => {
300 | const el = e.target.parentElement.parentElement.querySelector('[copy]');
301 | const selection = window.getSelection();
302 | const range = document.createRange();
303 | range.selectNodeContents(el);
304 | selection.removeAllRanges();
305 | selection.addRange(range);
306 | document.execCommand('copy');
307 |
308 | fireAlert('Copied to clipboard');
309 | };
310 | }
311 |
312 | function fireAlert(detail) {
313 | dispatchEvent(new CustomEvent('alert', { detail, bubbles: true }));
314 | }
315 |
316 | // Render main
317 | function getDecoratedImages({ searching, search, searchResults, images }) {
318 | const imagesToDecorate = (searching && ((searchResults && searchResults.hits) || [])) || images;
319 | return imagesToDecorate
320 | .map(image => addImageWidth({ image }))
321 | .map(image => addImageVersion({ image }));
322 | }
323 |
324 | function addImageWidth({ image }) {
325 | const height = HEIGHT;
326 | let width = DEFAULT_WIDTH;
327 | image = { ...image };
328 | if (image.exifTags) {
329 | const { ImageHeight, ImageWidth } = image.exifTags;
330 | if (ImageHeight && ImageWidth) {
331 | width = height / (ImageHeight / ImageWidth);
332 | }
333 | }
334 | image.width = width;
335 | return image;
336 | }
337 |
338 | function addImageVersion({ image }) {
339 | const version = (image.versions && image.versions[VERSION_NAME]) || {};
340 | image = { ...image };
341 | image.version = version;
342 | return image;
343 | }
344 |
345 | function justifyWidths({ images, gutter, imagesWidth }) {
346 | const rows = images.reduce(
347 | (rows, { ...image }) => {
348 | const lastRow = rows[rows.length - 1];
349 | const cumulativeWidths = sumRowWidths(lastRow);
350 | const lastRowWidth = gutter * lastRow.length + cumulativeWidths;
351 | if (lastRowWidth < imagesWidth) {
352 | lastRow.push(image);
353 | } else {
354 | rows.push([image]);
355 | }
356 | return rows;
357 | },
358 | [[]]
359 | );
360 | const adjustedRows = rows.map(row => {
361 | const goalWidth = imagesWidth - row.length * gutter;
362 | const totalWidth = sumRowWidths(row);
363 | const difference = totalWidth - goalWidth;
364 |
365 | if (difference > 0) {
366 | row.forEach(image => {
367 | const percentageOfRow = image.width / totalWidth;
368 | image.width = image.width - difference * percentageOfRow;
369 | });
370 | } else {
371 | row.push({ isGrower: true, width: -1 * difference });
372 | }
373 |
374 | return row;
375 | });
376 | return adjustedRows.reduce((flat, row) => flat.concat(row), []);
377 | }
378 |
379 | function sumRowWidths(row) {
380 | return row.reduce((sum, image) => sum + image.width, 0);
381 | }
382 |
383 | // Render image rows
384 | function getImageRow({
385 | environment,
386 | image,
387 | isAdmin,
388 | selection,
389 | copyClick,
390 | imageDetailClick,
391 | selectClick,
392 | }) {
393 | let li;
394 | if (image.isGrower) {
395 | li = ;
396 | } else {
397 | const { __id: id, _highlightResult: highlightResult } = image;
398 | const name = image.filename;
399 | const isSelected = selection.has(id);
400 | const markdown = getMarkdown({ environment, image });
401 | const tagItems = getTagsItems(image);
402 | const selectSvg = getSelectSvg({ isAdmin, selectClick });
403 |
404 | li = (
405 |
406 | {selectSvg}
407 |
408 |
409 |

410 |

416 |
417 |
424 |
425 |
426 | {markdown}
427 |
428 |
435 |
436 | );
437 | }
438 | return li;
439 | }
440 |
441 | function getMarkdown({ environment, image }) {
442 | let result = '';
443 | if (image.versions && image.versions[MARKDOWN_VERSION_NAME]) {
444 | const version = image.versions[MARKDOWN_VERSION_NAME];
445 | const url = version.url || version.shortUrl;
446 | const name = image.filename;
447 | result = ``;
448 | } else {
449 | loadImageVersionIfNecessary({ environment, image, versionName: MARKDOWN_VERSION_NAME });
450 | }
451 | return result;
452 | }
453 |
454 | function getTagsItems({ tags, _highlightResult: highlightResult }) {
455 | const searchTag =
456 | highlightResult && highlightResult.tags && highlightResult.tags.map(x => x.value);
457 | const decoratedTags = searchTag || tags || [];
458 | return decoratedTags.map(tag => {
459 | return (
460 |
465 | );
466 | });
467 | }
468 |
469 | function getSelectSvg({ isAdmin, selectClick }) {
470 | return (
471 | isAdmin && (
472 |
483 | )
484 | );
485 | }
486 |
--------------------------------------------------------------------------------