add blossom uploads

This commit is contained in:
hzrd149
2024-08-19 11:17:02 -05:00
parent 28c80f111b
commit 3e6ebc62cc
9 changed files with 270 additions and 9 deletions

View File

@@ -4,7 +4,6 @@
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import * as Alert from '@/components/ui/alert';
import Textarea from '@/components/ui/textarea/textarea.svelte';
import { ndk } from '@/ndk';
import { currentUser } from '@/stores/session';
import { NDKEvent } from '@nostr-dev-kit/ndk';
@@ -15,6 +14,9 @@
import CalculateSats from './CalculateSats.svelte';
import { isGitHubIssuesOrPullUrl, parseProblem } from '@/helpers';
import Login from './Login.svelte';
import RichTextArea from './RichTextArea.svelte';
import UploadMediaLink from './UploadMediaLink.svelte';
import type { BlobDescriptor } from 'blossom-client-sdk';
export let rocket: Rocket;
@@ -102,6 +104,10 @@
);
});
}
function handleUploaded(event: CustomEvent<BlobDescriptor>) {
solution += `\n${event.detail.url}\n`;
}
</script>
<Dialog.Root bind:open>
@@ -123,7 +129,7 @@
<div class="grid gap-4 overflow-auto py-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="name" class="text-right">Problem</Label>
<Textarea
<RichTextArea
bind:value={problem}
id="name"
placeholder="Describe the problem you solved, links to github are also acceptable"
@@ -132,12 +138,15 @@
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="desc" class="text-right">Solution (proof of work)</Label>
<Textarea
bind:value={solution}
id="desc"
placeholder="Link to your solution (e.g. a merged PR or some other evidence)"
class="col-span-3 {validateSolution(solution) ? 'border-green-700' : 'border-red-600'}"
/>
<div class="col-span-3">
<RichTextArea
bind:value={solution}
id="desc"
placeholder="Link to your solution (e.g. a merged PR or some other evidence)"
class={validateSolution(solution) ? 'border-green-700' : 'border-red-600'}
/>
<UploadMediaLink on:uploaded={handleUploaded}>Upload Image</UploadMediaLink>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="sats" class="text-right">Value of your work (Sats)</Label>

View File

@@ -14,6 +14,8 @@
import { base } from '$app/paths';
import { getRocketURL } from '@/helpers';
import Textarea from '@/components/ui/textarea/textarea.svelte';
import UploadMediaLink from './UploadMediaLink.svelte';
import type { BlobDescriptor } from 'blossom-client-sdk';
export let rocketEvent: NDKEvent;
@@ -48,6 +50,10 @@
goto(`${base}/rockets/${getRocketURL(rocketEvent)}`);
});
}
function handleUploaded(event: CustomEvent<BlobDescriptor>) {
image = event.detail.url;
}
</script>
<Dialog.Root bind:open={o}>
@@ -86,7 +92,10 @@
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="image" class="text-right">Cover Image</Label>
<Input bind:value={image} id="name" placeholder="URL of cover image" class="col-span-3" />
<div class="col-span-3">
<Input bind:value={image} id="name" placeholder="URL of cover image" />
<UploadMediaLink on:uploaded={handleUploaded}>Upload</UploadMediaLink>
</div>
</div>
</div>
<Dialog.Footer>

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import type { HTMLTextareaAttributes } from 'svelte/elements';
import Textarea from '../lib/components/ui/textarea/textarea.svelte';
import { onMount } from 'svelte';
import { fetchUsersServers, uploadBlob, userServers } from '../lib/blossom/servers';
import { ndk } from '../lib/ndk';
type $$Props = HTMLTextareaAttributes;
export let value: $$Props['value'] = undefined;
// NOTE: this is kind of hacky and should be moved to some kind of "login" event
onMount(() => {
$ndk.signer?.user().then((user) => {
if ($userServers.length === 0 && $ndk.signer) fetchUsersServers(user.pubkey);
});
});
const handlePaste = async (event: ClipboardEvent) => {
const clipboardData = event.clipboardData;
if (!clipboardData) return;
// find the first image item
const item = Array.from(clipboardData.items).find((item) => item.type.startsWith('image/'));
if (!item) return;
try {
const file = item.getAsFile();
if (file) {
const blob = await uploadBlob(file, $userServers);
// Insert the image URL into the textarea
const insert = blob.url;
const textarea = event.target as HTMLTextAreaElement;
const startPos = textarea.selectionStart;
const endPos = textarea.selectionEnd;
const current = String(value);
value = current.substring(0, startPos) + insert + current.substring(endPos);
}
event.preventDefault();
} catch (error) {
if (error instanceof Error) alert(`Image upload failed: ${error.message}`);
}
};
</script>
<Textarea bind:value on:paste={handlePaste} {...$$restProps} />

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { fetchUsersServers, uploadBlob, userServers } from '../lib/blossom/servers';
import { ndk } from '../lib/ndk';
const dispatch = createEventDispatcher();
let uploading = false;
let uploadInput: HTMLInputElement;
// NOTE: this is kind of hacky and should be moved to some kind of "login" event
onMount(() => {
$ndk.signer?.user().then((user) => {
if ($userServers.length === 0 && $ndk.signer) fetchUsersServers(user.pubkey);
});
});
const handleUpload = async (event: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
const file = event.currentTarget.files?.item(0);
if (file) {
try {
dispatch('uploading');
const blob = await uploadBlob(file, $userServers);
dispatch('uploaded', blob);
} catch (error) {
if (error instanceof Error) alert(`Failed to upload image: ${error.message}`);
}
}
};
</script>
<input
bind:this={uploadInput}
type="file"
class="hidden"
on:change={handleUpload}
disabled={uploading || null}
/>
<button on:click={() => uploadInput.click()} class="hover:underline">
{#if uploading}
Uploading...
{:else}
<slot />
{/if}
</button>

View File

@@ -0,0 +1,33 @@
import { onMount } from 'svelte';
export function pasteImage(node: HTMLTextAreaElement) {
const handlePaste = (event: ClipboardEvent) => {
const clipboardData = event.clipboardData;
if (!clipboardData) return;
const items = clipboardData.items;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) {
// Create a URL for the image file
const imageUrl = URL.createObjectURL(file);
// Insert the image URL into the textarea
node.value += `\n![Image](${imageUrl})\n`;
}
event.preventDefault();
}
}
};
// Attach the paste event listener
node.addEventListener('paste', handlePaste);
// Clean up the event listener on unmount
return {
destroy() {
node.removeEventListener('paste', handlePaste);
}
};
}

View File

@@ -0,0 +1,37 @@
import { get, writable } from 'svelte/store';
import { BlossomClient, getServersFromServerListEvent } from 'blossom-client-sdk';
import { ndk } from '../ndk';
import { signEventTemplate } from './signer';
export const userServers = writable<URL[]>([]);
export async function fetchUsersServers(pubkey: string) {
const ndkSvelte = get(ndk);
const event = await ndkSvelte.fetchEvent({ kinds: [10063 as number], authors: [pubkey] });
if (!event) userServers.set([]);
else userServers.set(getServersFromServerListEvent(event));
}
export async function uploadBlob(file: File, servers?: URL[]) {
if (!servers) servers = get(userServers);
if (servers.length === 0) throw new Error('User does not have any blossom servers');
const sha256 = await BlossomClient.getFileSha256(file);
const auth = await BlossomClient.createUploadAuth(sha256, signEventTemplate, 'Upload Image');
const blob = await BlossomClient.uploadBlob(servers[0], file, auth);
// mirror blob to other servers in background
for (const server of servers.slice(1)) {
BlossomClient.mirrorBlob(server, blob.url, auth)
.catch((err) => {
// not all servers support mirroring, so attempt to upload
return BlossomClient.uploadBlob(server, file, auth);
})
.catch((error) => {
// upload filed, ignore error
});
}
return blob;
}

16
src/lib/blossom/signer.ts Normal file
View File

@@ -0,0 +1,16 @@
import { get } from 'svelte/store';
import { NDKEvent } from '@nostr-dev-kit/ndk';
import type { EventTemplate, SignedEvent } from 'blossom-client-sdk';
import { ndk } from '../ndk';
export async function signEventTemplate(template: EventTemplate): Promise<SignedEvent> {
const _ndk = get(ndk);
const e = new NDKEvent(_ndk);
e.kind = template.kind;
e.content = template.content;
e.tags = template.tags;
e.created_at = template.created_at;
await e.sign();
return e.rawEvent() as SignedEvent;
}