├── .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 |
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 |
2 |
3 |
4 |
13 | {{ info }}
14 |
17 |
18 |
25 |
26 |
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 |
2 |
3 |
4 |
5 |
58 |
59 |
60 |
drop(idx, e)"
69 | @dragend.native="dragend"
70 | @dragover.native="allowDrop"
71 | @dragenter.native="allowDrop"
72 | @dragstart.native="e=>drag(idx, e)"
73 | @upload="urls=>handleUploaded(idx,urls)"
74 | />
75 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
104 |
105 |
106 |
107 | {{ toast }}
108 |
109 |
118 | Drop here to Remove
119 |
120 |
121 |
122 |
123 |
312 |
313 |
494 |
--------------------------------------------------------------------------------