Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Photo storage example #355

Merged
merged 16 commits into from Dec 15, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/hosting-photo-storage-example.yml
@@ -0,0 +1,47 @@
name: hosting-photo-storage
on:
push:
branches:
- master
pull_request:
paths:
- hosting/photo-storage/**
- .github/workflows/provision-darwin.sh
- .github/workflows/provision-linux.sh
- .github/workflows/hosting-photo-storage-example.yml
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
hosting-photo-storage-darwin:
runs-on: macos-12
steps:
- uses: actions/checkout@v1
- name: Provision Darwin
run: bash .github/workflows/provision-darwin.sh
- name: Hosting Photo Storage Darwin
run: |
pushd hosting/photo-storage
# verify frontend deps install and build
npm install
npm run build
# verify that frontend asset canister deploys
dfx start --background
dfx deploy
popd
hosting-photo-storage-linux:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v1
- name: Provision Linux
run: bash .github/workflows/provision-linux.sh
- name: Hosting Photo Storage Linux
run: |
pushd hosting/photo-storage
# verify frontend deps install and build
npm install
npm run build
# verify that frontend asset canister deploys
dfx start --background
dfx deploy
popd
23 changes: 23 additions & 0 deletions hosting/photo-storage/.gitignore
@@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js

# testing
/coverage

# production
/build

# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local

npm-debug.log*
yarn-debug.log*
yarn-error.log*
73 changes: 73 additions & 0 deletions hosting/photo-storage/README.md
@@ -0,0 +1,73 @@
# Photo storage example

The example shows how to store photos on the IC in an asset canister with the `@dfinity/assets` package. The photo
storage app is deployed as a frontend in an asset canister which is also used for photo upload.

## Installation

This example project can be cloned, installed and deployed locally, for learning and testing purposes. The instructions
are based on running the example on either macOS or Linux, but when using WSL2 on Windows, the instructions will be the
same.

### Prerequisites

The example project requires the following installed:

- git
- dfx
- npm

git and npm can be installed from various package managers. DFX can be installed following the
instructions [here](https://internetcomputer.org/docs/current/developer-docs/build/install-upgrade-remove).

### Download the code

Clone the example dapp project:

```bash
$ git clone https://github.com/dfinity/examples
$ cd examples/hosting/photo-storage
```

## React build

The React frontend is build by running this command:

```bash
npm install
npm run build
```

## Deployment

The local network is started by running this command:

```bash
$ dfx start --clean --background
```

When the local network is up and running, run this command to deploy the canisters:

```bash
$ dfx deploy
```

## Authorization

To authorize the identity from this example project on a local network to upload files, it must be authorized first:

```bash
dfx canister call photo-storage authorize '(principal "535yc-uxytb-gfk7h-tny7p-vjkoe-i4krp-3qmcl-uqfgr-cpgej-yqtjq-rqe")'
```

Before deployment on the IC, the hardcoded identity (defined in `src/App.js`) should be replaced by an authentication
method e.g. Internet Identity.

## Cats

The example cat stock photos are from [Pexels](https://www.pexels.com/license/).

## License

This project is licensed under the Apache 2.0 license, see LICENSE.md for details. See CONTRIBUTE.md for details about
how to contribute to this project.
20 changes: 20 additions & 0 deletions hosting/photo-storage/dfx.json
@@ -0,0 +1,20 @@
{
"canisters": {
"photo-storage": {
"frontend": {
"entrypoint": "build/index.html"
},
"source": [
"build"
],
"type": "assets"
}
},
"defaults": {
"build": {
"args": "",
"packtool": ""
}
},
"version": 1
}
40 changes: 40 additions & 0 deletions hosting/photo-storage/package.json
@@ -0,0 +1,40 @@
{
"name": "photo-storage",
"version": "0.1.0",
"private": true,
"dependencies": {
"@dfinity/agent": "^0.15.0",
"@dfinity/assets": "^0.15.0",
"@dfinity/candid": "^0.15.0",
"@dfinity/identity": "^0.15.0",
"@dfinity/principal": "^0.15.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-masonry-css": "^1.0.16",
"react-scripts": "5.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
12 changes: 12 additions & 0 deletions hosting/photo-storage/public/index.html
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Photo storage</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
55 changes: 55 additions & 0 deletions hosting/photo-storage/src/App.css
@@ -0,0 +1,55 @@
.App-wrapper {
padding: 10px 10px 0 10px;
}

.App-masonry {
display: flex;
margin-left: -10px;
width: auto;
}

.App-masonry-column {
padding-left: 10px;
background-clip: padding-box;
}

.App-upload {
background: #222222;
color: #ffffff;
font-family: sans-serif;
font-weight: 600;
width: 100%;
height: 200px;
margin-bottom: 10px;
cursor: pointer;
}

.App-upload:hover {
background: #333333;
}

.App-image {
margin-bottom: 10px;
background: #eeeeee;
}

.App-image img {
display: block;
width: 100%;
}

.App-progress {
position: fixed;
top: 50%;
left: 50%;
margin: -30px 0 0 -50px;
width: 100px;
height: 60px;
background: #222222;
font-family: sans-serif;
text-align: center;
line-height: 60px;
font-weight: 600;
color: #ffffff;
box-shadow: 0 0 0 9999px rgba(255, 255, 255, 0.8);
}
107 changes: 107 additions & 0 deletions hosting/photo-storage/src/App.js
@@ -0,0 +1,107 @@
import {Ed25519KeyIdentity} from '@dfinity/identity';
import {HttpAgent} from '@dfinity/agent';
import {AssetManager} from '@dfinity/assets';
import {useEffect, useState} from "react";
import Masonry from "react-masonry-css";
import './App.css';

// Hardcoded principal: 535yc-uxytb-gfk7h-tny7p-vjkoe-i4krp-3qmcl-uqfgr-cpgej-yqtjq-rqe
// Should be replaced with authentication method e.g. Internet Identity when deployed on IC
const identity = Ed25519KeyIdentity.generate(new Uint8Array(Array.from({length: 32}).fill(0)));
const isLocal = !window.location.host.endsWith('ic0.app');
const agent = new HttpAgent({
host: isLocal ? `http://127.0.0.1:${window.location.port}` : 'https://ic0.app', identity,
});
if (isLocal) {
agent.fetchRootKey();
}

// Canister id can be fetched from URL since frontend in this example is hosted in the same canister as file upload
const canisterId = new URLSearchParams(window.location.search).get('canisterId') ?? /(.*?)(?:\.raw)?\.ic0.app/.exec(window.location.host)?.[1] ?? /(.*)\.localhost/.exec(window.location.host)?.[1];

// Create asset manager instance for above asset canister
const assetManager = new AssetManager({canisterId, agent});

// Get file name, width and height from key
const detailsFromKey = (key) => {
const fileName = key.split('/').slice(-1)[0];
const width = parseInt(fileName.split('.').slice(-3)[0]);
const height = parseInt(fileName.split('.').slice(-2)[0]);
return {key, fileName, width, height}
}

// Get file name, width and height from file
const detailsFromFile = async (file) => {
const src = await new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.readAsDataURL(file);
})
const [width, height] = await new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve([img.naturalWidth, img.naturalHeight]);
img.src = src;
})
const name = file.name.split('.');
const extension = name.pop();
const fileName = [name, width, height, extension].join('.');
return {fileName, width, height}
}

const App = () => {
const [uploads, setUploads] = useState([]);
const [progress, setProgress] = useState(null);

useEffect(() => {
assetManager.list()
.then(assets => assets
.filter(asset => asset.key.startsWith('/uploads/'))
.sort((a, b) => Number(b.encodings[0].modified - a.encodings[0].modified))
.map(({key}) => detailsFromKey(key)))
.then(setUploads);
}, []);

const uploadPhotos = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.multiple = true;
input.onchange = async () => {
setProgress(0);
try {
const batch = assetManager.batch();
const items = await Promise.all(Array.from(input.files).map(async (file) => {
const {fileName, width, height} = await detailsFromFile(file);
const key = await batch.store(file, {path: '/uploads', fileName});
return {key, fileName, width, height};
}));
await batch.commit({onProgress: ({current, total}) => setProgress(current / total)});
setUploads(prevState => [...items, ...prevState])
} catch (e) {
if (e.message.includes('Caller is not authorized')) {
alert("Caller is not authorized, follow Authorization instructions in README");
} else {
throw e;
}
}
setProgress(null)
};
input.click();
}

return (
<div className={'App-wrapper'}>
<Masonry breakpointCols={{default: 4, 600: 2, 800: 3}} className={'App-masonry'}
columnClassName="App-masonry-column">
<button className={'App-upload'} onClick={uploadPhotos}>📂 Upload photo</button>
{uploads.map(upload => (
<div key={upload.key} className={'App-image'} style={{aspectRatio: upload.width / upload.height}}>
<img src={upload.key} alt={upload.fileName} loading={'lazy'}/>
</div>))}
</Masonry>
{progress !== null && <div className={'App-progress'}>{Math.round(progress * 100)}%</div>}
</div>
);
}

export default App;