diff --git a/src/components/common/LoadingSpinner.svelte b/src/components/common/LoadingSpinner.svelte new file mode 100644 index 0000000..e371470 --- /dev/null +++ b/src/components/common/LoadingSpinner.svelte @@ -0,0 +1,28 @@ +
+ + diff --git a/src/components/gallery/imageGallery/ImageCard.svelte b/src/components/gallery/imageGallery/ImageCard.svelte new file mode 100644 index 0000000..ae893b4 --- /dev/null +++ b/src/components/gallery/imageGallery/ImageCard.svelte @@ -0,0 +1,26 @@ + + +
+
+
+
+ {`Gallery +
+
+
diff --git a/src/components/gallery/imageGallery/ImageGallery.svelte b/src/components/gallery/imageGallery/ImageGallery.svelte new file mode 100644 index 0000000..33b258e --- /dev/null +++ b/src/components/gallery/imageGallery/ImageGallery.svelte @@ -0,0 +1,57 @@ + + +
+
+
+ + {#each $galleryStore.selectedArea === AREAS.PRIVATE ? $galleryStore.privateImages : $galleryStore.publicImages as image} + + {/each} +
+
+ + {#if selectedImage} + + {/if} +
diff --git a/src/components/gallery/imageGallery/ImageModal.svelte b/src/components/gallery/imageGallery/ImageModal.svelte new file mode 100644 index 0000000..2d13cb0 --- /dev/null +++ b/src/components/gallery/imageGallery/ImageModal.svelte @@ -0,0 +1,164 @@ + + + + +{#if !!image} + + + +{/if} diff --git a/src/components/gallery/upload/Dropzone.svelte b/src/components/gallery/upload/Dropzone.svelte new file mode 100644 index 0000000..4c40603 --- /dev/null +++ b/src/components/gallery/upload/Dropzone.svelte @@ -0,0 +1,69 @@ + + + diff --git a/src/components/gallery/upload/FileUploadCard.svelte b/src/components/gallery/upload/FileUploadCard.svelte new file mode 100644 index 0000000..3d566c3 --- /dev/null +++ b/src/components/gallery/upload/FileUploadCard.svelte @@ -0,0 +1,41 @@ + + +
+ +
diff --git a/src/components/icons/FileUploadIcon.svelte b/src/components/icons/FileUploadIcon.svelte new file mode 100644 index 0000000..6135e5d --- /dev/null +++ b/src/components/icons/FileUploadIcon.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/components/notifications/Notification.svelte b/src/components/notifications/Notification.svelte new file mode 100644 index 0000000..bec67b8 --- /dev/null +++ b/src/components/notifications/Notification.svelte @@ -0,0 +1,38 @@ + + +
+
+
+ {#if notification.type === 'success'} + + {:else if notification.type === 'error'} + + {/if} + {@html notification.msg} +
+
+
diff --git a/src/components/notifications/Notifications.svelte b/src/components/notifications/Notifications.svelte new file mode 100644 index 0000000..47094c9 --- /dev/null +++ b/src/components/notifications/Notifications.svelte @@ -0,0 +1,15 @@ + + +{#if $notificationStore.length} +
+ {#each $notificationStore as notification (notification.id)} +
+ +
+ {/each} +
+{/if} diff --git a/src/lib/common/utils.ts b/src/lib/common/utils.ts index 0a03944..6034b1d 100644 --- a/src/lib/common/utils.ts +++ b/src/lib/common/utils.ts @@ -2,7 +2,7 @@ export function asyncDebounce( fn: (...args: A) => Promise, wait: number ): (...args: A) => Promise { - let lastTimeoutId: ReturnType | undefined = undefined + let lastTimeoutId: ReturnType | undefined = undefined return (...args: A): Promise => { clearTimeout(lastTimeoutId) @@ -22,4 +22,31 @@ export function asyncDebounce( lastTimeoutId = currentTimeoutId }) } -} \ No newline at end of file +} + +/** + * 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) + ) diff --git a/src/lib/common/webnative.ts b/src/lib/common/webnative.ts index 45c29b2..08e8d77 100644 --- a/src/lib/common/webnative.ts +++ b/src/lib/common/webnative.ts @@ -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 => { 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 => { username, authed: true })) -} \ No newline at end of file +} diff --git a/src/lib/gallery.ts b/src/lib/gallery.ts new file mode 100644 index 0000000..68e0cec --- /dev/null +++ b/src/lib/gallery.ts @@ -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 = 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 = 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 = 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 = async files => { + await Promise.all( + Array.from(files).map(async file => { + await uploadImageToWNFS(file) + }) + ) + + // Refetch images and update galleryStore + await getImagesFromWNFS() +} diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts new file mode 100644 index 0000000..008974f --- /dev/null +++ b/src/lib/notifications.ts @@ -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 +} diff --git a/src/routes/__layout.svelte b/src/routes/__layout.svelte index 3d361dd..555159f 100644 --- a/src/routes/__layout.svelte +++ b/src/routes/__layout.svelte @@ -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 @@ -
+
+ {#if session.error} + 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) + + +
+ {#if $sessionStore.authed} +
+
+ {#each Object.keys(AREAS) as area} + + {/each} +
+
+ + + + + {/if} +
diff --git a/src/stores.ts b/src/stores.ts index 74f45f8..a00b378 100644 --- a/src/stores.ts +++ b/src/stores.ts @@ -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 = writable({ export const filesystemStore: Writable = writable(null) export const deviceStore: Writable = writable({ isMobile: true }) + +export const galleryStore: Writable = writable({ + loading: true, + publicImages: [], + privateImages: [], + selectedArea: AREAS.PUBLIC, +}) + +export const notificationStore: Writable = writable([])