├── .eslintignore ├── .gitignore ├── .vscode └── settings.json ├── static ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── examples │ └── photo-1588055312392-97068a233ee2.jpeg ├── site.webmanifest └── index.html ├── .eslintrc.js ├── renovate.json ├── package.json ├── index.html ├── LICENSE ├── README.md └── src ├── Post.vue ├── utils.js └── App.vue /.eslintignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | web_modules 3 | dist 4 | .DS_Store -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Foto", 4 | "rgbs" 5 | ] 6 | } -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antfu/foto-rehearse/master/static/favicon.ico -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: '@antfu/eslint-config-vue', 4 | } 5 | -------------------------------------------------------------------------------- /static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antfu/foto-rehearse/master/static/favicon-16x16.png -------------------------------------------------------------------------------- /static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antfu/foto-rehearse/master/static/favicon-32x32.png -------------------------------------------------------------------------------- /static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antfu/foto-rehearse/master/static/apple-touch-icon.png -------------------------------------------------------------------------------- /static/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antfu/foto-rehearse/master/static/android-chrome-192x192.png -------------------------------------------------------------------------------- /static/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antfu/foto-rehearse/master/static/android-chrome-512x512.png -------------------------------------------------------------------------------- /static/examples/photo-1588055312392-97068a233ee2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antfu/foto-rehearse/master/static/examples/photo-1588055312392-97068a233ee2.jpeg -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | ":preserveSemverRanges", 5 | "schedule:weekly", 6 | "group:allNonMajor", 7 | ":maintainLockFilesWeekly" 8 | ], 9 | "lockFileMaintenance": { 10 | "extends": [ 11 | "group:allNonMajor" 12 | ], 13 | "commitMessageAction": "Update" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /static/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "foto-rehearse", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "rimraf dist && mkdir dist && vite build && cp ./static/*.* dist/ && cp -R ./static/examples dist" 7 | }, 8 | "dependencies": { 9 | "@vueuse/core": "^3.0.26", 10 | "vue": "^3.0.0-beta.4" 11 | }, 12 | "devDependencies": { 13 | "@antfu/eslint-config-vue": "^0.2.11", 14 | "@vue/compiler-sfc": "^3.0.0-beta.4", 15 | "eslint": "^6.8.0", 16 | "pug": "^2.0.4", 17 | "pug-plain-loader": "^1.0.0", 18 | "rimraf": "^3.0.2", 19 | "snowpack": "^1.7.1", 20 | "stylus": "^0.54.7", 21 | "stylus-loader": "^3.0.2", 22 | "typescript": "^3.8.3", 23 | "vite": "^0.5.1" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | Foto Rehearse 3 | 4 | 8 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Anthony Fu 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | intro 3 |

4 | 5 |

6 | Free and Open photography post planner. 7 |

8 | 9 |

10 | 11 | Try the App now! 12 | 13 |

14 | 15 |

16 | Powered by Vite, VueUse and ♥️ with the new Vue Composition API! 17 |

18 | 19 | ## Checklist 20 | 21 | - [x] Batch importing 22 | - [x] Drag'n'Drop 23 | - [x] Data persistent (via IndexedDB) 24 | - [x] Sessions/Tabs 25 | - [x] Dark mode 26 | - [x] Color Palettes 27 | - [x] HSV info 28 | - [x] Take screenshot 29 | - [ ] Crop 30 | - [ ] Guide/Help 31 | 32 | ### Maybe 33 | 34 | - [ ] Import from Instagram account 35 | 36 | ### Non-Goal 37 | 38 | - Post scheduler 39 | - Built-in photo editing 40 | 41 | ## License 42 | 43 | The code is licensed under [MIT](./LICENSE) and photos in the screenshots are from https://unsplash.com/ with their [Unsplash License](https://unsplash.com/license). 44 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | Foto Rehearse 3 | 4 | 5 | 6 | 7 | 8 | 12 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/Post.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 134 | 135 | 191 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-alert */ 2 | import { ref, onMounted, onUnmounted, watch } from 'vue' 3 | 4 | export const STORE_PREFIX = 'foto-rehearse-posts' 5 | export const CONFIG_PREFIX = 'foto-rehearse-config' 6 | const DEFAULT_POSTS = 8 7 | const DEFAULT_IMAGES = [ 8 | '/examples/photo-1588055312392-97068a233ee2.jpeg', 9 | ] 10 | 11 | function useEventListener(type, listener, options, target) { 12 | if (target === undefined) target = window 13 | onMounted(() => { 14 | target.addEventListener(type, listener, options) 15 | }) 16 | onUnmounted(() => { 17 | target.removeEventListener(type, listener, options) 18 | }) 19 | } 20 | 21 | export async function takeScreenshot( 22 | selector = '#phone-case-inner', 23 | filename = `foto-rehearsal-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`, 24 | ) { 25 | return new Promise((resolve) => { 26 | window.html2canvas( 27 | document.querySelector(selector), 28 | { scale: 4 }, 29 | ) 30 | .then((canvas) => { 31 | canvas.toBlob((blob) => { 32 | window.saveAs(blob, filename) 33 | resolve() 34 | }) 35 | }) 36 | }) 37 | } 38 | 39 | export function useWindowSize() { 40 | const width = ref(window.innerWidth) 41 | const height = ref(window.innerHeight) 42 | useEventListener('resize', () => { 43 | width.value = window.innerWidth 44 | height.value = window.innerHeight 45 | }) 46 | return { width, height } 47 | } 48 | 49 | const Serializers = { 50 | boolean: { 51 | read(v) { return v === 'true' }, 52 | write(v) { return String(v) }, 53 | }, 54 | object: { 55 | read(v, d) { return v ? JSON.parse(v) : d }, 56 | write(v) { return JSON.stringify(v) }, 57 | }, 58 | number: { 59 | read(v, d) { return v != null ? Number.parseFloat(v) : d }, 60 | write(v) { return String(v) }, 61 | }, 62 | any: { 63 | read(v, d) { return v !== null && v !== undefined ? v : d }, 64 | write(v) { return String(v) }, 65 | }, 66 | string: { 67 | read(v, d) { return v !== null && v !== undefined ? v : d }, 68 | write(v) { return String(v) }, 69 | }, 70 | } 71 | 72 | export function useStorage(key, defaultValue, storage) { 73 | if (storage === undefined) storage = localStorage 74 | const data = ref(defaultValue) 75 | const type = defaultValue == null 76 | ? 'any' 77 | : typeof defaultValue === 'boolean' 78 | ? 'boolean' 79 | : typeof defaultValue === 'string' 80 | ? 'string' 81 | : typeof defaultValue === 'object' 82 | ? 'object' 83 | // @ts-ignore 84 | : !Number.isNaN(defaultValue) 85 | ? 'number' 86 | : 'any' 87 | function read() { 88 | try { 89 | let rawValue = storage.getItem(key) 90 | if (rawValue === undefined && defaultValue) { 91 | rawValue = Serializers[type].write(defaultValue) 92 | storage.setItem(key, rawValue) 93 | } 94 | else { 95 | data.value = Serializers[type].read(rawValue, defaultValue) 96 | } 97 | } 98 | catch (e) { 99 | console.warn(e) 100 | } 101 | } 102 | read() 103 | useEventListener('storage', read) 104 | watch(data, () => { 105 | try { 106 | if (data.value == null) 107 | storage.removeItem(key) 108 | else 109 | storage.setItem(key, Serializers[type].write(data.value)) 110 | } 111 | catch (e) { 112 | console.warn(e) 113 | } 114 | }, { flush: 'sync', deep: true }) 115 | return data 116 | } 117 | 118 | export function openDb() { 119 | return new Promise((resolve) => { 120 | const request = window.indexedDB.open(STORE_PREFIX, 1) 121 | 122 | request.onerror = function(event) { 123 | alert(`Failed to open db:\n${event.toString()}`) 124 | } 125 | 126 | request.onsuccess = function(event) { 127 | resolve(request.result) 128 | } 129 | 130 | request.onupgradeneeded = function(event) { 131 | const db = event.target.result 132 | const stores = [] 133 | for (let i = 0; i < 5; i++) { 134 | if (!db.objectStoreNames.contains(`posts-${i}`)) 135 | stores.push(db.createObjectStore(`posts-${i}`, { keyPath: 'id' })) 136 | } 137 | 138 | for (let i = 0; i < DEFAULT_POSTS; i++) 139 | stores[0].put({ id: i, url: DEFAULT_IMAGES[i] || '' }) 140 | } 141 | }) 142 | } 143 | 144 | export function loadPosts(db, tab = 0) { 145 | return new Promise((resolve) => { 146 | const store = db.transaction([`posts-${tab}`], 'readwrite') 147 | .objectStore(`posts-${tab}`) 148 | const request = store.getAll() 149 | request.onsuccess = () => { 150 | let posts = request.result 151 | if (!posts || !posts.length) { 152 | posts = new Array(DEFAULT_POSTS) 153 | .fill(null) 154 | .map((_, id) => ({ id, url: '' })) 155 | } 156 | posts.sort((a, b) => a.id - b.id) 157 | resolve(posts) 158 | } 159 | }) 160 | } 161 | 162 | export function savePosts(db, posts = [], tab = 0) { 163 | const store = db.transaction([`posts-${tab}`], 'readwrite') 164 | .objectStore(`posts-${tab}`) 165 | 166 | const count = posts.length 167 | 168 | store.clear() 169 | 170 | for (let i = 0; i < count; i++) 171 | store.put({ id: i.toString(), url: posts[i].url }) 172 | } 173 | 174 | export function resizedataURL(url, MAX_WIDTH = 512, MAX_HEIGHT = 512) { 175 | const img = document.createElement('img') 176 | 177 | return new Promise((resolve) => { 178 | img.onload = function() { 179 | // We create a canvas and get its context. 180 | const canvas = document.createElement('canvas') 181 | const ctx = canvas.getContext('2d') 182 | 183 | let width = img.width 184 | let height = img.height 185 | 186 | if (width > height) { 187 | if (width > MAX_WIDTH) { 188 | height *= MAX_WIDTH / width 189 | width = MAX_WIDTH 190 | } 191 | } 192 | else { 193 | if (height > MAX_HEIGHT) { 194 | width *= MAX_HEIGHT / height 195 | height = MAX_HEIGHT 196 | } 197 | } 198 | 199 | canvas.width = width 200 | canvas.height = height 201 | 202 | ctx.drawImage(this, 0, 0, width, height) 203 | 204 | resolve(canvas.toDataURL()) 205 | 206 | img.remove() 207 | } 208 | 209 | img.src = url 210 | }) 211 | } 212 | 213 | export async function getDataUrls(files) { 214 | return await Promise.all( 215 | Array 216 | .from(files) 217 | .map((file) => { 218 | return new Promise((resolve) => { 219 | const reader = new FileReader() 220 | reader.addEventListener('load', async() => { 221 | const url = reader.result 222 | const resized = await resizedataURL(url, 512, 512) 223 | resolve(resized) 224 | }, false) 225 | 226 | reader.readAsDataURL(file) 227 | }) 228 | }), 229 | ) 230 | } 231 | 232 | export async function popup(url, name, width, height) { 233 | return new Promise((resolve) => { 234 | const newWin = window.open(url, name, `height=${height},width=${width}`) 235 | newWin.addEventListener('beforeunload', () => resolve()) 236 | if (window.focus) 237 | newWin.focus() 238 | }) 239 | } 240 | 241 | export function rgbToHex(r, g, b) { 242 | return `#${[r, g, b].map((x) => { 243 | const hex = x.toString(16) 244 | return hex.length === 1 ? `0${hex}` : hex 245 | }).join('')}` 246 | } 247 | 248 | export async function getColors(url, amount = 5) { 249 | const img = document.createElement('img') 250 | await new Promise((resolve) => { 251 | img.onload = () => resolve() 252 | img.src = url 253 | }) 254 | const palette = new window.ColorThief().getPalette(img, amount) 255 | return palette.map(rgb => rgbToHex(...rgb)) 256 | } 257 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 122 | 123 | 312 | 313 | 494 | --------------------------------------------------------------------------------