mirror of
https://github.com/coracle-social/coracle.git
synced 2024-09-18 19:23:40 +00:00
Add image uploads to profile edit page
This commit is contained in:
parent
91df2e2247
commit
490f38fb5e
22
ROADMAP.md
22
ROADMAP.md
@ -1,8 +1,18 @@
|
|||||||
# Current
|
# Current
|
||||||
|
|
||||||
|
- [ ] Strip zero width spaces from compose
|
||||||
- [ ] Fix iOS
|
- [ ] Fix iOS
|
||||||
- [ ] Make the note relays button modal make sense, one relay with no explanation is not good
|
- [ ] Make the note relays button modal make sense, one relay with no explanation is not good
|
||||||
|
|
||||||
|
# Image uploads
|
||||||
|
|
||||||
|
- Default will charge via lightning and have a tos, others can self-host and skip that.
|
||||||
|
- https://github.com/ElementsProject/lightning-charge
|
||||||
|
- https://github.com/nostr-protocol/nips/pull/250
|
||||||
|
- https://github.com/brandonsavage/Upload
|
||||||
|
- https://github.com/seaweedfs/seaweedfs
|
||||||
|
- https://github.com/cubefs/cubefs
|
||||||
|
|
||||||
# Lightning
|
# Lightning
|
||||||
|
|
||||||
- [ ] Linkify invoices
|
- [ ] Linkify invoices
|
||||||
@ -18,6 +28,11 @@
|
|||||||
|
|
||||||
# More
|
# More
|
||||||
|
|
||||||
|
- [ ] Add webtorrent support
|
||||||
|
- https://coracle.social/nevent1qqsxgxcsq5vevy4wdty5z5v88nhwp2fc5qgl0ws5rmamn6z72hwv3qcpyfmhxue69uhkummnw3ez6an9wf5kv6t9vsh8wetvd3hhyer9wghxuet5qk6c9q
|
||||||
|
- [ ] Add coracle relay
|
||||||
|
- Authenticated write, public read
|
||||||
|
- Only accepts events from people with a @coracle.social nip05
|
||||||
- [ ] Apply person popover to mentions in notes as well
|
- [ ] Apply person popover to mentions in notes as well
|
||||||
- [ ] Invite link, nprofile + path that prompts someone to sign in or create an account and auto-follow the inviter
|
- [ ] Invite link, nprofile + path that prompts someone to sign in or create an account and auto-follow the inviter
|
||||||
- [ ] Cache follower numbers to avoid re-fetching so much
|
- [ ] Cache follower numbers to avoid re-fetching so much
|
||||||
@ -63,13 +78,6 @@
|
|||||||
- All replies from author's + user's read relays, including spam
|
- All replies from author's + user's read relays, including spam
|
||||||
- [ ] Topics/hashtag views
|
- [ ] Topics/hashtag views
|
||||||
- [ ] Re-license using https://polyformproject.org/
|
- [ ] Re-license using https://polyformproject.org/
|
||||||
- [ ] Image uploads
|
|
||||||
- Default will charge via lightning and have a tos, others can self-host and skip that.
|
|
||||||
- Add banner field to profile
|
|
||||||
- Linode/Digital Ocean
|
|
||||||
- https://github.com/brandonsavage/Upload
|
|
||||||
- https://github.com/seaweedfs/seaweedfs
|
|
||||||
- https://github.com/cubefs/cubefs
|
|
||||||
- [ ] Separate settings for read, write, and broadcast relays based on NIP 65
|
- [ ] Separate settings for read, write, and broadcast relays based on NIP 65
|
||||||
- [ ] Release to android
|
- [ ] Release to android
|
||||||
- https://svelte-native.technology/docs
|
- https://svelte-native.technology/docs
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import type {MyEvent} from 'src/util/types'
|
import type {MyEvent} from 'src/util/types'
|
||||||
import {assoc, partition, uniq, uniqBy, prop, propEq, reject, groupBy, pluck} from 'ramda'
|
import {partition, assoc, uniq, uniqBy, prop, propEq, reject, groupBy, pluck} from 'ramda'
|
||||||
import {personKinds, findReplyId} from 'src/util/nostr'
|
import {personKinds, findReplyId} from 'src/util/nostr'
|
||||||
import {log} from 'src/util/logger'
|
import {log} from 'src/util/logger'
|
||||||
import {chunk} from 'hurdak/lib/hurdak'
|
import {chunk} from 'hurdak/lib/hurdak'
|
||||||
|
86
src/partials/ImageInput.svelte
Normal file
86
src/partials/ImageInput.svelte
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {filter, identity} from 'ramda'
|
||||||
|
import Input from "src/partials/Input.svelte"
|
||||||
|
import Modal from "src/partials/Modal.svelte"
|
||||||
|
import Content from "src/partials/Content.svelte"
|
||||||
|
import Anchor from "src/partials/Anchor.svelte"
|
||||||
|
import {listenForFile, stripExifData, blobToFile} from "src/util/html"
|
||||||
|
import {uploadFile, postJson} from "src/util/misc"
|
||||||
|
import user from "src/agent/user"
|
||||||
|
|
||||||
|
export let value
|
||||||
|
export let icon
|
||||||
|
export let maxWidth = null
|
||||||
|
export let maxHeight = null
|
||||||
|
|
||||||
|
let input, file, listener, quote
|
||||||
|
let loading = false
|
||||||
|
let isOpen = false
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (input) {
|
||||||
|
listener = listenForFile(input, async inputFile => {
|
||||||
|
const opts = filter(identity, {maxWidth, maxHeight})
|
||||||
|
|
||||||
|
file = blobToFile(await stripExifData(inputFile, opts))
|
||||||
|
quote = await postJson(user.dufflepud('/upload/quote'), {
|
||||||
|
uploads: [{size: file.size}],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accept = async () => {
|
||||||
|
loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {id} = quote.uploads[0]
|
||||||
|
const {url} = await uploadFile(user.dufflepud(`/upload/${id}`), file)
|
||||||
|
|
||||||
|
value = url
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
file = null
|
||||||
|
quote = null
|
||||||
|
isOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const decline = () => {
|
||||||
|
file = null
|
||||||
|
quote = null
|
||||||
|
isOpen = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Input type="text" wrapperClass="flex-grow" bind:value={value} placeholder="https://">
|
||||||
|
<i slot="before" class={`fa fa-${icon}`} />
|
||||||
|
</Input>
|
||||||
|
<Anchor type="button" on:click={() => { isOpen = true }}>
|
||||||
|
<i class="fa fa-upload" />
|
||||||
|
</Anchor>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if quote}
|
||||||
|
<Modal onEscape={decline}>
|
||||||
|
<Content>
|
||||||
|
<h1 class="staatliches text-2xl">Confirm File Upload</h1>
|
||||||
|
<p>Please accept the following terms:</p>
|
||||||
|
<p>{quote.terms}</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Anchor type="button" on:click={decline} {loading}>Decline</Anchor>
|
||||||
|
<Anchor type="button-accent" on:click={accept} {loading}>Accept</Anchor>
|
||||||
|
</div>
|
||||||
|
</Content>
|
||||||
|
</Modal>
|
||||||
|
{:else if isOpen}
|
||||||
|
<Modal onEscape={decline}>
|
||||||
|
<Content>
|
||||||
|
<h1 class="staatliches text-2xl">Upload a File</h1>
|
||||||
|
<p>Click below to select a file to upload.</p>
|
||||||
|
<input type="file" bind:this={input} />
|
||||||
|
</Content>
|
||||||
|
</Modal>
|
||||||
|
{/if}
|
@ -1,4 +1,4 @@
|
|||||||
import {ellipsize} from 'hurdak/lib/hurdak'
|
import {ellipsize, bytes} from 'hurdak/lib/hurdak'
|
||||||
|
|
||||||
export const copyToClipboard = text => {
|
export const copyToClipboard = text => {
|
||||||
const {activeElement} = document
|
const {activeElement} = document
|
||||||
@ -16,7 +16,7 @@ export const copyToClipboard = text => {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export const stripExifData = async file => {
|
export const stripExifData = async (file, opts = {}) => {
|
||||||
if (window.DataTransferItem && file instanceof DataTransferItem) {
|
if (window.DataTransferItem && file instanceof DataTransferItem) {
|
||||||
file = file.getAsFile()
|
file = file.getAsFile()
|
||||||
}
|
}
|
||||||
@ -31,9 +31,10 @@ export const stripExifData = async file => {
|
|||||||
|
|
||||||
return new Promise((resolve, _reject) => {
|
return new Promise((resolve, _reject) => {
|
||||||
new Compressor(file, {
|
new Compressor(file, {
|
||||||
maxWidth: 50,
|
maxWidth: 1024,
|
||||||
maxHeight: 50,
|
maxHeight: 1024,
|
||||||
convertSize: 1024,
|
convertSize: bytes(1, 'mb'),
|
||||||
|
...opts,
|
||||||
success: resolve,
|
success: resolve,
|
||||||
error: e => {
|
error: e => {
|
||||||
// Non-images break compressor
|
// Non-images break compressor
|
||||||
@ -47,6 +48,31 @@ export const stripExifData = async file => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const listenForFile = (input, onChange) => {
|
||||||
|
input.addEventListener('change', async e => {
|
||||||
|
const target = e.target as HTMLInputElement
|
||||||
|
const [file] = target.files
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
onChange(file)
|
||||||
|
} else {
|
||||||
|
onChange(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const blobToString = async blob =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
|
||||||
|
reader.onerror = reject
|
||||||
|
reader.onload = () => resolve(reader.result)
|
||||||
|
reader.readAsDataURL(blob)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const blobToFile = blob =>
|
||||||
|
new File([blob], blob.name, {type: blob.type})
|
||||||
|
|
||||||
export const escapeHtml = html => {
|
export const escapeHtml = html => {
|
||||||
const div = document.createElement("div")
|
const div = document.createElement("div")
|
||||||
|
|
||||||
|
@ -152,6 +152,13 @@ export class Cursor {
|
|||||||
this.until = now()
|
this.until = now()
|
||||||
this.limit = limit
|
this.limit = limit
|
||||||
}
|
}
|
||||||
|
getFilter() {
|
||||||
|
return {
|
||||||
|
until: this.until,
|
||||||
|
since: this.until - timedelta(8, 'hours'),
|
||||||
|
limit: this.limit,
|
||||||
|
}
|
||||||
|
}
|
||||||
update(events) {
|
update(events) {
|
||||||
// update takes all events in a feed and figures out the best place to set `until`
|
// update takes all events in a feed and figures out the best place to set `until`
|
||||||
// in order to find older events without re-fetching events that we've already seen.
|
// in order to find older events without re-fetching events that we've already seen.
|
||||||
@ -269,12 +276,54 @@ export const difference = (a, b) =>
|
|||||||
new Set(Array.from(a).filter(x => !b.has(x)))
|
new Set(Array.from(a).filter(x => !b.has(x)))
|
||||||
|
|
||||||
export const quantile = (a, q) => {
|
export const quantile = (a, q) => {
|
||||||
const sorted = sortBy(identity, a)
|
const sorted = sortBy(identity, a)
|
||||||
const pos = (sorted.length - 1) * q
|
const pos = (sorted.length - 1) * q
|
||||||
const base = Math.floor(pos)
|
const base = Math.floor(pos)
|
||||||
const rest = pos - base
|
const rest = pos - base
|
||||||
|
|
||||||
return isNil(sorted[base + 1])
|
return isNil(sorted[base + 1])
|
||||||
? sorted[base]
|
? sorted[base]
|
||||||
: sorted[base] + rest * (sorted[base + 1] - sorted[base])
|
: sorted[base] + rest * (sorted[base + 1] - sorted[base])
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchOpts = {
|
||||||
|
method?: string
|
||||||
|
headers?: Record<string, string | boolean>
|
||||||
|
body?: string | FormData
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchJson = async (url, opts: FetchOpts = {}) => {
|
||||||
|
if (!opts.headers) {
|
||||||
|
opts.headers = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.headers['Accept'] = 'application/json'
|
||||||
|
|
||||||
|
const res = await fetch(url, opts as RequestInit)
|
||||||
|
const json = await res.json()
|
||||||
|
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
|
export const postJson = async (url, data, opts: FetchOpts = {}) => {
|
||||||
|
if (!opts.method) {
|
||||||
|
opts.method = 'POST'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!opts.headers) {
|
||||||
|
opts.headers = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.headers['Content-Type'] = 'application/json'
|
||||||
|
opts.body = JSON.stringify(data)
|
||||||
|
|
||||||
|
return fetchJson(url, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uploadFile = (url, fileObj) => {
|
||||||
|
const body = new FormData()
|
||||||
|
|
||||||
|
body.append("file", fileObj)
|
||||||
|
|
||||||
|
return fetchJson(url, {method: 'POST', body})
|
||||||
}
|
}
|
||||||
|
@ -2,8 +2,8 @@
|
|||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {fly} from 'svelte/transition'
|
import {fly} from 'svelte/transition'
|
||||||
import {navigate} from "svelte-routing"
|
import {navigate} from "svelte-routing"
|
||||||
// import {stripExifData} from "src/util/html"
|
|
||||||
import Input from "src/partials/Input.svelte"
|
import Input from "src/partials/Input.svelte"
|
||||||
|
import ImageInput from "src/partials/ImageInput.svelte"
|
||||||
import Textarea from "src/partials/Textarea.svelte"
|
import Textarea from "src/partials/Textarea.svelte"
|
||||||
import Anchor from "src/partials/Anchor.svelte"
|
import Anchor from "src/partials/Anchor.svelte"
|
||||||
import Button from "src/partials/Button.svelte"
|
import Button from "src/partials/Button.svelte"
|
||||||
@ -24,29 +24,11 @@
|
|||||||
if (!user.getProfile()) {
|
if (!user.getProfile()) {
|
||||||
return navigate("/login")
|
return navigate("/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
document.querySelector('[name=picture]').addEventListener('change', async e => {
|
|
||||||
const target = e.target as HTMLInputElement
|
|
||||||
const [file] = target.files
|
|
||||||
|
|
||||||
if (file) {
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onerror = error
|
|
||||||
reader.onload = () => values.picture = reader.result
|
|
||||||
reader.readAsDataURL(await stripExifData(file))
|
|
||||||
} else {
|
|
||||||
values.picture = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
*/
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const submit = async event => {
|
const submit = async event => {
|
||||||
event.preventDefault()
|
event?.preventDefault()
|
||||||
|
|
||||||
publishWithToast(getUserWriteRelays(), cmd.updateUser(values))
|
publishWithToast(getUserWriteRelays(), cmd.updateUser(values))
|
||||||
|
|
||||||
navigate(routes.person(user.getPubkey(), 'profile'))
|
navigate(routes.person(user.getPubkey(), 'profile'))
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@ -91,28 +73,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<strong>Profile Picture</strong>
|
<strong>Profile Picture</strong>
|
||||||
<!-- <input type="file" name="picture" />
|
<ImageInput bind:value={values.picture} icon="image-portrait" maxWidth={480} maxHeight={480} />
|
||||||
<p class="text-sm text-light">
|
<p class="text-sm text-light">
|
||||||
Your profile image will have all metadata removed before being published.
|
Please be mindful of others and only use small images.
|
||||||
</p> -->
|
|
||||||
<Input type="text" name="picture" wrapperClass="flex-grow" bind:value={values.picture}>
|
|
||||||
<i slot="before" class="fa fa-image-portrait" />
|
|
||||||
</Input>
|
|
||||||
<p class="text-sm text-light">
|
|
||||||
Enter a url to your profile image - please be mindful of others and
|
|
||||||
only use small images.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<strong>Profile Banner</strong>
|
<strong>Profile Banner</strong>
|
||||||
<Input type="text" name="banner" wrapperClass="flex-grow" bind:value={values.banner}>
|
<ImageInput bind:value={values.banner} icon="panorama" />
|
||||||
<i slot="before" class="fa fa-panorama" />
|
|
||||||
</Input>
|
|
||||||
<p class="text-sm text-light">
|
<p class="text-sm text-light">
|
||||||
Enter a url to your profile's banner image.
|
In most clients, this image will be shown on your profile page.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" class="text-center">Done</Button>
|
<Button type="submit" class="text-center">Save</Button>
|
||||||
</div>
|
</div>
|
||||||
</Content>
|
</Content>
|
||||||
</form>
|
</form>
|
||||||
|
@ -28,10 +28,10 @@
|
|||||||
onChunk,
|
onChunk,
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadMessages = ({until, limit}, onChunk) =>
|
const loadMessages = (cursor, onChunk) =>
|
||||||
network.load({
|
network.load({
|
||||||
relays: getRelays(),
|
relays: getRelays(),
|
||||||
filter: {kinds: [42], '#e': [id], until, limit},
|
filter: {kinds: [42], '#e': [id], ...cursor.getFilter()},
|
||||||
onChunk,
|
onChunk,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -54,10 +54,10 @@
|
|||||||
onChunk: async events => onChunk(await decryptMessages(events)),
|
onChunk: async events => onChunk(await decryptMessages(events)),
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadMessages = ({until, limit}, onChunk) =>
|
const loadMessages = (cursor, onChunk) =>
|
||||||
network.load({
|
network.load({
|
||||||
relays: getRelays(),
|
relays: getRelays(),
|
||||||
filter: getFilters({until, limit}),
|
filter: getFilters(cursor.getFilter()),
|
||||||
onChunk: async events => onChunk(await decryptMessages(events)),
|
onChunk: async events => onChunk(await decryptMessages(events)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -85,9 +85,7 @@
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const {limit, until} = cursor
|
return network.load({relays, filter: {...filter, ...cursor.getFilter()}, onChunk})
|
||||||
|
|
||||||
return network.load({relays, filter: {...filter, until, limit}, onChunk})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
Loading…
Reference in New Issue
Block a user