3 private and public photo uploads (#37)

This commit is contained in:
Andrew Vivash 2022-08-27 15:53:10 -07:00 committed by GitHub
parent 82a389a42e
commit 319ff70b5d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 797 additions and 4 deletions

View File

@ -0,0 +1,28 @@
<div
class="loader ease-linear rounded-full border-4 border-t-4 border-neutral h-16 w-16"
/>
<style>
.loader {
border-top-color: #3498db;
animation: spinner 1s linear infinite;
}
@-webkit-keyframes spinner {
0% {
-webkit-transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
}
}
@keyframes spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@ -0,0 +1,26 @@
<script lang="ts">
import type { Image } from '$lib/gallery'
export let image: Image
export let openModal
const handleOpenModal = () => openModal(image)
</script>
<div class="flex flex-wrap w-1/2 sm:w-1/4 lg:w-1/6">
<div
class="relative group w-full m-1 md:m-2 rounded-lg border-4 border-transparent hover:border-primary overflow-hidden transition-colors ease-in"
on:click={handleOpenModal}
>
<div
class="flex items-center justify-center absolute z-10 top-0 right-0 bottom-0 left-0 bg-[#00000035] opacity-0 group-hover:opacity-100 transition-opacity ease-in"
/>
<div class="relative pb-[100%]">
<img
class="absolute block object-cover object-center w-full h-full"
alt={`Gallery Image: ${image.name}`}
src={image.src}
/>
</div>
</div>
</div>

View File

@ -0,0 +1,57 @@
<script lang="ts">
import { onDestroy } from 'svelte'
import { galleryStore } from '../../../stores'
import { AREAS, getImagesFromWNFS } from '$lib/gallery'
import type { Image } from '$lib/gallery'
import FileUploadCard from '$components/gallery/upload/FileUploadCard.svelte'
import ImageCard from '$components/gallery/imageGallery/ImageCard.svelte'
import ImageModal from '$components/gallery/imageGallery/ImageModal.svelte'
// Get images from the user's public WNFS
getImagesFromWNFS()
/**
* Open the ImageModal and pass it the selected `image` from the gallery
* @param image
*/
let selectedImage: Image
const setSelectedImage: (image: Image) => void = image =>
(selectedImage = image)
const clearSelectedImage = () => (selectedImage = null)
// If galleryStore.selectedArea changes from private to public, re-run getImagesFromWNFS
let selectedArea = null
const unsubscribe = galleryStore.subscribe(async updatedStore => {
// Get initial selectedArea
if (!selectedArea) {
selectedArea = updatedStore.selectedArea
}
if (selectedArea !== updatedStore.selectedArea) {
selectedArea = updatedStore.selectedArea
await getImagesFromWNFS()
}
})
onDestroy(unsubscribe)
</script>
<section class="overflow-hidden text-gray-700">
<div class="p-4 mx-auto">
<div class="flex flex-wrap -m-1 md:-m-2">
<FileUploadCard />
{#each $galleryStore.selectedArea === AREAS.PRIVATE ? $galleryStore.privateImages : $galleryStore.publicImages as image}
<ImageCard {image} openModal={setSelectedImage} />
{/each}
</div>
</div>
{#if selectedImage}
<ImageModal
image={selectedImage}
isModalOpen={!!selectedImage}
on:close={clearSelectedImage}
/>
{/if}
</section>

View File

@ -0,0 +1,164 @@
<script lang="ts">
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
import { galleryStore } from '../../../stores'
import type { Gallery, Image } from '$lib/gallery'
import { deleteImageFromWNFS } from '$lib/gallery'
export let image: Image
export let isModalOpen: boolean = false
let previousImage: Image | undefined
let nextImage: Image | undefined
let showPreviousArrow: boolean
let showNextArrow: boolean
let gallery: Gallery
const dispatch = createEventDispatcher()
const unsubcribe = galleryStore.subscribe(newState => (gallery = newState))
/**
* Close the modal, clear the image state vars, set `isModalOpen` to false
* and dispatch the close event to clear the image from the parent's state
*/
const handleCloseModal: () => void = () => {
image = null
previousImage = null
nextImage = null
isModalOpen = false
dispatch('close')
}
/**
* Delete an image from the user's WNFS
*/
const handleDeleteImage: () => Promise<void> = async () => {
await deleteImageFromWNFS(image.name)
handleCloseModal()
}
/**
* Set the previous and next images to be toggled to when the arrows are clicked
*/
const setCarouselState = () => {
const imageList = image.private
? gallery.privateImages
: gallery.publicImages
const currentIndex = imageList.findIndex(val => val.cid === image.cid)
previousImage =
imageList[currentIndex - 1] ?? imageList[imageList.length - 1]
nextImage = imageList[currentIndex + 1] ?? imageList[0]
showPreviousArrow = imageList.length > 1 && !!previousImage
showNextArrow = imageList.length > 1 && !!nextImage
}
/**
* Load the correct image when a user clicks the Next or Previous arrows
* @param direction
*/
const handleNextOrPrevImage: (
direction: 'next' | 'prev'
) => void = direction => {
image = direction === 'prev' ? previousImage : nextImage
setCarouselState()
}
/**
* Detect `Escape` key presses to close the modal or `ArrowRight`/`ArrowLeft`
* presses to navigate the carousel
* @param event
*/
const handleKeyDown: (event: KeyboardEvent) => void = event => {
if (event.key === 'Escape') handleCloseModal()
if (showNextArrow && event.key === 'ArrowRight')
handleNextOrPrevImage('next')
if (showPreviousArrow && event.key === 'ArrowLeft')
handleNextOrPrevImage('prev')
}
onMount(() => {
setCarouselState()
})
// Unsubscribe from galleryStore updates
onDestroy(unsubcribe)
</script>
<svelte:window on:keydown={handleKeyDown} />
{#if !!image}
<!-- bind:checked can't be set to !!image, so we need to set it to a boolean(casting image as a boolean throws a svelte error, so we're using isModalOpen) -->
<input
type="checkbox"
id={`image-modal-${image.cid}`}
class="modal-toggle"
bind:checked={isModalOpen}
/>
<label
for={`image-modal-${image.cid}`}
class="modal cursor-pointer z-50"
on:click|self={handleCloseModal}
>
<div class="modal-box relative text-center text-base-content">
<label
for={`image-modal-${image.cid}`}
class="btn btn-xs btn-circle absolute right-2 top-2"
on:click={handleCloseModal}
>
</label>
<div>
<h3 class="mb-7 text-xl font-serif">{image.name}</h3>
<div class="relative">
{#if showPreviousArrow}
<button
class="absolute top-1/2 -left-[25px] -translate-y-1/2 inline-block text-center text-[40px]"
on:click={() => handleNextOrPrevImage('prev')}
>
&#8249;
</button>
{/if}
<img
class="block object-cover object-center w-full h-full mb-4"
alt={`Image: ${image.name}`}
src={image.src}
/>
{#if showNextArrow}
<button
class="absolute top-1/2 -right-[25px] -translate-y-1/2 inline-block text-center text-[40px]"
on:click={() => handleNextOrPrevImage('next')}
>
&#8250;
</button>
{/if}
</div>
<div class="flex flex-col items-center justify-center">
<a
href={`https://ipfs.runfission.net/ipfs/${image.cid}/userland`}
target="_blank"
class="underline mb-4"
>
View on IPFS
</a>
<p class="mb-4">
Created at {new Date(image.ctime).toDateString()}
</p>
<div class="flex items-center justify-between gap-4">
<a href={image.src} download={image.name} class="btn btn-primary">
Download Image
</a>
<button
class="btn bg-error text-white"
on:click={handleDeleteImage}
>
Delete Image
</button>
</div>
</div>
</div>
</div>
</label>
{/if}

View File

@ -0,0 +1,69 @@
<script lang="ts">
import { getImagesFromWNFS, uploadImageToWNFS } from '$lib/gallery'
import { addNotification } from '$lib/notifications'
/**
* Detect when a user drags a file in or out of the dropzone to change the styles
*/
let isDragging = false
const handleDragEnter: () => void = () => (isDragging = true)
const handleDragLeave: () => void = () => (isDragging = false)
/**
* Process files being dropped in the drop zone and ensure they are images
* @param event
*/
const handleDrop: (event: DragEvent) => Promise<void> = async event => {
// Prevent default behavior (Prevent file from being opened)
event.preventDefault()
const files = Array.from(event.dataTransfer.items)
console.log(`${files.length} file${files.length > 1 ? 's' : ''} dropped`)
// Iterate over the dropped files and upload them to WNFS
await Promise.all(
files.map(async (item, index) => {
if (item.kind === 'file') {
const file: File = item.getAsFile()
// If the dropped files aren't images, we don't want them!
if (!file.type.match('image/*')) {
addNotification('Please upload images only', 'error')
console.error('Please upload images only')
} else {
console.log(`file[${index + 1}].name = ${file.name}`)
await uploadImageToWNFS(file)
}
}
})
)
// Refetch images and update galleryStore
await getImagesFromWNFS()
// Disable isDragging state
isDragging = false
}
/**
* This is needed to prevent the default behaviour of the file opening in browser
* when it is dropped
* @param event
*/
const handleDragOver: (event: DragEvent) => void = event =>
event.preventDefault()
</script>
<label
on:drop={handleDrop}
on:dragover={handleDragOver}
on:dragenter={handleDragEnter}
on:dragleave={handleDragLeave}
for="dropzone-file"
class="block w-full min-h-[calc(100vh-154px)] rounded-lg border-2 border-gray-300 border-dashed transition-colors ease-in cursor-pointer {isDragging
? '!border-primary'
: ''}"
>
<slot />
</label>

View File

@ -0,0 +1,41 @@
<script lang="ts">
import { galleryStore } from '../../../stores'
import { handleFileInput } from '$lib/gallery'
import FileUploadIcon from '$components/icons/FileUploadIcon.svelte'
import LoadingSpinner from '$components/common/LoadingSpinner.svelte'
// Handle files uploaded directly through the file input
let files: FileList
$: if (files) {
handleFileInput(files)
}
</script>
<div class="flex flex-wrap w-1/2 sm:w-1/4 lg:w-1/6">
<label
for="upload-file"
class="group flex flex-col justify-center items-center w-full m-2 md:m-3 object-cover rounded-lg hover:border-primary overflow-hidden text-gray-500 dark:text-gray-400 hover:text-primary transition-colors ease-in bg-base-100 border-2 box-border border-gray-300 border-dashed cursor-pointer"
>
{#if $galleryStore.loading}
<div class="flex justify-center items-center p-12">
<LoadingSpinner />
</div>
{:else}
<div class="flex flex-col justify-center items-center pt-5 pb-6">
<FileUploadIcon />
<p class="mb-2 text-sm">
<span class="font-semibold">Click to upload</span>
</p>
<p class="text-xs">SVG, PNG, JPG or GIF</p>
</div>
<input
bind:files
id="upload-file"
type="file"
multiple
accept="image/*"
class="hidden"
/>
{/if}
</label>
</div>

View File

@ -0,0 +1,19 @@
<script lang="ts">
export let classes: string = 'mb-3 w-10 h-10'
</script>
<svg
aria-hidden="true"
class={classes}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 0 1-.88-7.903A5 5 0 1 1 15.9 6h.1a5 5 0 0 1 1 9.9M15 13l-3-3m0 0-3 3m3-3v12"
/>
</svg>

View File

@ -0,0 +1,38 @@
<script lang="ts">
import { fade, fly } from 'svelte/transition'
import CheckThinIcon from '$components/icons/CheckThinIcon.svelte'
import XThinIcon from '$components/icons/XThinIcon.svelte'
import { theme as themeStore } from '../../stores'
interface Notification {
msg?: string
type?: string
}
export let notification: Notification
</script>
<div
in:fly={{ y: 20, duration: 400 }}
out:fade
role="alert"
aria-live="assertive"
aria-atomic="true"
>
<div
class="alert {notification.type === 'success'
? 'alert-success'
: 'alert-error'} text-sm mb-3"
>
<div>
{#if notification.type === 'success'}
<CheckThinIcon
color={$themeStore === 'light' ? '#b8ffd3' : '#002e12'}
/>
{:else if notification.type === 'error'}
<XThinIcon color={$themeStore === 'light' ? '#ffd6d7' : '#fec3c3'} />
{/if}
<span class="pl-1">{@html notification.msg}</span>
</div>
</div>
</div>

View File

@ -0,0 +1,15 @@
<script lang="ts">
import { flip } from 'svelte/animate'
import Notification from '$components/notifications/Notification.svelte'
import { notificationStore } from '../../stores'
</script>
{#if $notificationStore.length}
<div class="fixed z-50 right-6 bottom-8 flex flex-col justify-center">
{#each $notificationStore as notification (notification.id)}
<div animate:flip>
<Notification {notification} />
</div>
{/each}
</div>
{/if}

View File

@ -2,7 +2,7 @@ export function asyncDebounce<A extends unknown[], R>(
fn: (...args: A) => Promise<R>,
wait: number
): (...args: A) => Promise<R> {
let lastTimeoutId: ReturnType<typeof setTimeout> | undefined = undefined
let lastTimeoutId: ReturnType<typeof setTimeout> | undefined = undefined
return (...args: A): Promise<R> => {
clearTimeout(lastTimeoutId)
@ -22,4 +22,31 @@ export function asyncDebounce<A extends unknown[], R>(
lastTimeoutId = currentTimeoutId
})
}
}
}
/**
* Util to convert a Uint8Array to a string
* @param u8array
* @returns string
*/
export const convertUint8ToString: (u8array: Uint8Array) => string = u8array => {
const CHUNK_SZ = 0x8000
const c = []
for (let i = 0; i < u8array.length; i += CHUNK_SZ) {
c.push(String.fromCharCode.apply(null, u8array.subarray(i, i + CHUNK_SZ)))
}
return c.join('')
}
/**
* Generate a new uuid
* @returns uuid
*/
export const uuid: () => string = () =>
// @ts-expect-error disable number[] + number warning
([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c: number) =>
(
c ^
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
).toString(16)
)

View File

@ -3,6 +3,7 @@ import { setup } from 'webnative'
import { asyncDebounce } from '$lib/common/utils'
import { filesystemStore, sessionStore } from '../../stores'
import { AREAS, GALLERY_DIRS } from '$lib/gallery'
import { getBackupStatus, type BackupStatus } from '$lib/auth/backup'
// runfission.net = staging
@ -91,6 +92,10 @@ export const register = async (username: string): Promise<boolean> => {
const fs = await webnative.bootstrapRootFileSystem()
filesystemStore.set(fs)
// Create public and private directories for the gallery
await fs.mkdir(webnative.path.directory(...GALLERY_DIRS[AREAS.PUBLIC]))
await fs.mkdir(webnative.path.directory(...GALLERY_DIRS[AREAS.PRIVATE]))
sessionStore.update(session => ({
...session,
username,
@ -109,4 +114,4 @@ export const loadAccount = async (username: string): Promise<void> => {
username,
authed: true
}))
}
}

192
src/lib/gallery.ts Normal file
View File

@ -0,0 +1,192 @@
import { get as getStore } from 'svelte/store'
import * as wn from 'webnative'
import { filesystemStore, galleryStore } from '../stores'
import { convertUint8ToString } from '$lib/common/utils'
import { addNotification } from '$lib/notifications'
export enum AREAS {
PUBLIC = 'Public',
PRIVATE = 'Private',
}
export type Image = {
cid: string
ctime: number
name: string
private: boolean
size: number
src: string
}
export type Gallery = {
publicImages: Image[] | null
privateImages: Image[] | null
selectedArea: AREAS
loading: boolean
}
export const GALLERY_DIRS = {
[AREAS.PUBLIC]: ['public', 'gallery'],
[AREAS.PRIVATE]: ['private', 'gallery'],
}
const FILE_SIZE_LIMIT = 5
/**
* Get images from the user's WNFS and construct the `src` value for the images
*/
export const getImagesFromWNFS: () => Promise<void> = async () => {
try {
// Set loading: true on the galleryStore
galleryStore.update((store) => ({ ...store, loading: true }))
const { selectedArea } = getStore(galleryStore)
const isPrivate = selectedArea === AREAS.PRIVATE
const fs = getStore(filesystemStore)
// Set path to either private or public gallery dir
const path = wn.path.directory(...GALLERY_DIRS[selectedArea])
// Get list of links for files in the gallery dir
const links = await fs.ls(path)
const images = await Promise.all(
Object.entries(links).map(async ([name]) => {
const file = await fs.get(
wn.path.file(...GALLERY_DIRS[selectedArea], `${name}`)
)
// The CID for private files is currently located in `file.header.content`,
// whereas the CID for public files is located in `file.cid`
const cid = isPrivate ? file.header.content.toString() : file.cid.toString()
// Create a base64 string to use as the image `src`
const src = `data:image/jpeg;base64, ${btoa(
convertUint8ToString(file.content as Uint8Array)
)}`
return {
cid,
ctime: file.header.metadata.unixMeta.ctime,
name,
private: isPrivate,
size: links[name].size,
src,
}
})
)
// Sort images by ctime(created at date)
// NOTE: this will eventually be controlled via the UI
images.sort((a, b) => b.ctime - a.ctime)
// Push images to the galleryStore
galleryStore.update((store) => ({
...store,
...(isPrivate ? {
privateImages: images,
} : {
publicImages: images,
}),
loading: false,
}))
} catch (error) {
console.error(error)
galleryStore.update(store => ({
...store,
loading: false,
}))
}
}
/**
* Upload an image to the user's private or public WNFS
* @param image
*/
export const uploadImageToWNFS: (
image: File
) => Promise<void> = async image => {
try {
const { selectedArea } = getStore(galleryStore)
const fs = getStore(filesystemStore)
// Reject files over 5MB
const imageSizeInMB = image.size / (1024 * 1024)
if (imageSizeInMB > FILE_SIZE_LIMIT) {
throw new Error('Image can be no larger than 5MB')
}
// Reject the upload if the image already exists in the directory
const imageExists = await fs.exists(
wn.path.file(...GALLERY_DIRS[selectedArea], image.name)
)
if (imageExists) {
throw new Error(`${image.name} image already exists`)
}
// Create a sub directory and add some content
await fs.write(
wn.path.file(...GALLERY_DIRS[selectedArea], image.name),
image
)
// Announce the changes to the server
await fs.publish()
console.log(`${image.name} image has been published`)
addNotification(`${image.name} image has been published`, 'success')
} catch (error) {
addNotification(error.message, 'error')
console.log(error)
}
}
/**
* Delete an image from the user's private or public WNFS
* @param name
*/
export const deleteImageFromWNFS: (name: string) => Promise<void> = async (name) => {
try {
const { selectedArea } = getStore(galleryStore)
const fs = getStore(filesystemStore)
const imageExists = await fs.exists(
wn.path.file(...GALLERY_DIRS[selectedArea], name)
)
if (imageExists) {
// Remove images from server
await fs.rm(wn.path.file(...GALLERY_DIRS[selectedArea], name))
// Announce the changes to the server
await fs.publish()
console.log(`${name} image has been deleted`)
addNotification(`${name} image has been deleted`, 'success')
// Refetch images and update galleryStore
await getImagesFromWNFS()
} else {
throw new Error(`${name} image has already been deleted`)
}
} catch (error) {
addNotification(error.message, 'error')
console.error(error)
}
}
/**
* Handle uploads made by interacting with the file input directly
*/
export const handleFileInput: (
files: FileList
) => Promise<void> = async files => {
await Promise.all(
Array.from(files).map(async file => {
await uploadImageToWNFS(file)
})
)
// Refetch images and update galleryStore
await getImagesFromWNFS()
}

44
src/lib/notifications.ts Normal file
View File

@ -0,0 +1,44 @@
import { notificationStore } from '../stores'
import { uuid } from '$lib/common/utils'
export type Notification = {
id?: string
msg?: string
type?: string
timeout?: number
}
export const removeNotification: (id: string) => void = id => {
notificationStore.update(all =>
all.filter(notification => notification.id !== id)
)
}
export const addNotification: (
msg: string,
type?: string,
timeout?: number
) => void = (msg, type = 'info', timeout = 5000) => {
// uuid for each notification
const id = uuid()
// adding new notifications to the bottom of the list so they stack from bottom to top
notificationStore.update(rest => [
...rest,
{
id,
msg,
type,
timeout,
}
])
// removing the notification after a specified timeout
const timer = setTimeout(() => {
removeNotification(id)
clearTimeout(timer)
}, timeout)
// return the id
return id
}

View File

@ -7,6 +7,7 @@
import { deviceStore, sessionStore, theme } from '../stores'
import { errorToMessage, type Session } from '$lib/session'
import Toast from '$components/notifications/Toast.svelte'
import Notifications from '$components/notifications/Notifications.svelte'
import Header from '$components/Header.svelte'
let session: Session = null
@ -45,8 +46,9 @@
<svelte:window on:resize={setDevice} />
<div data-theme={$theme} class="h-screen">
<div data-theme={$theme} class="min-h-screen">
<Header />
<Notifications />
{#if session.error}
<Toast

54
src/routes/gallery.svelte Normal file
View File

@ -0,0 +1,54 @@
<script lang="ts">
import { onDestroy } from 'svelte'
import { goto } from '$app/navigation'
import { galleryStore, sessionStore, theme as themeStore } from '../stores'
import Dropzone from '$components/gallery/upload/Dropzone.svelte'
import ImageGallery from '$components/gallery/imageGallery/ImageGallery.svelte'
import { AREAS } from '$lib/gallery'
/**
* Tab between the public/private areas and load associated images
* @param area
*/
const handleChangeTab: (area: AREAS) => void = area =>
galleryStore.update(store => ({
...store,
selectedArea: area
}))
// If the user is not authed redirect them to the home page
const unsubscribe = sessionStore.subscribe(newState => {
if (!newState.loading && !newState.authed) {
goto('/')
}
})
onDestroy(unsubscribe)
</script>
<div class="p-2 text-center">
{#if $sessionStore.authed}
<div class="flex mb-4">
<div
class="tabs tabs-boxed w-fit border {$themeStore === 'light'
? 'button-transparent'
: 'border-primary'}"
>
{#each Object.keys(AREAS) as area}
<button
on:click={() => handleChangeTab(AREAS[area])}
class="tab {$galleryStore.selectedArea === AREAS[area]
? 'tab-active'
: 'hover:text-primary'} ease-in"
>
{AREAS[area]} WNFS
</button>
{/each}
</div>
</div>
<Dropzone>
<ImageGallery />
</Dropzone>
{/if}
</div>

View File

@ -4,6 +4,9 @@ import type FileSystem from 'webnative/fs/index'
import { loadTheme } from '$lib/theme'
import type { Device } from '$lib/device'
import { AREAS } from '$lib/gallery'
import type { Gallery } from '$lib/gallery'
import type { Notification } from '$lib/notifications'
import type { Session } from '$lib/session'
import type { Theme } from '$lib/theme'
@ -19,3 +22,12 @@ export const sessionStore: Writable<Session> = writable({
export const filesystemStore: Writable<FileSystem | null> = writable(null)
export const deviceStore: Writable<Device> = writable({ isMobile: true })
export const galleryStore: Writable<Gallery> = writable({
loading: true,
publicImages: [],
privateImages: [],
selectedArea: AREAS.PUBLIC,
})
export const notificationStore: Writable<Notification[]> = writable([])