3 private and public photo uploads (#37)
This commit is contained in:
parent
82a389a42e
commit
319ff70b5d
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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')}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</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')}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</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}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -2,7 +2,7 @@ export function asyncDebounce<A extends unknown[], R>(
|
||||||
fn: (...args: A) => Promise<R>,
|
fn: (...args: A) => Promise<R>,
|
||||||
wait: number
|
wait: number
|
||||||
): (...args: A) => Promise<R> {
|
): (...args: A) => Promise<R> {
|
||||||
let lastTimeoutId: ReturnType<typeof setTimeout> | undefined = undefined
|
let lastTimeoutId: ReturnType<typeof setTimeout> | undefined = undefined
|
||||||
|
|
||||||
return (...args: A): Promise<R> => {
|
return (...args: A): Promise<R> => {
|
||||||
clearTimeout(lastTimeoutId)
|
clearTimeout(lastTimeoutId)
|
||||||
|
|
@ -22,4 +22,31 @@ export function asyncDebounce<A extends unknown[], R>(
|
||||||
lastTimeoutId = currentTimeoutId
|
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)
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { setup } from 'webnative'
|
||||||
|
|
||||||
import { asyncDebounce } from '$lib/common/utils'
|
import { asyncDebounce } from '$lib/common/utils'
|
||||||
import { filesystemStore, sessionStore } from '../../stores'
|
import { filesystemStore, sessionStore } from '../../stores'
|
||||||
|
import { AREAS, GALLERY_DIRS } from '$lib/gallery'
|
||||||
import { getBackupStatus, type BackupStatus } from '$lib/auth/backup'
|
import { getBackupStatus, type BackupStatus } from '$lib/auth/backup'
|
||||||
|
|
||||||
// runfission.net = staging
|
// runfission.net = staging
|
||||||
|
|
@ -91,6 +92,10 @@ export const register = async (username: string): Promise<boolean> => {
|
||||||
const fs = await webnative.bootstrapRootFileSystem()
|
const fs = await webnative.bootstrapRootFileSystem()
|
||||||
filesystemStore.set(fs)
|
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 => ({
|
sessionStore.update(session => ({
|
||||||
...session,
|
...session,
|
||||||
username,
|
username,
|
||||||
|
|
@ -109,4 +114,4 @@ export const loadAccount = async (username: string): Promise<void> => {
|
||||||
username,
|
username,
|
||||||
authed: true
|
authed: true
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import { deviceStore, sessionStore, theme } from '../stores'
|
import { deviceStore, sessionStore, theme } from '../stores'
|
||||||
import { errorToMessage, type Session } from '$lib/session'
|
import { errorToMessage, type Session } from '$lib/session'
|
||||||
import Toast from '$components/notifications/Toast.svelte'
|
import Toast from '$components/notifications/Toast.svelte'
|
||||||
|
import Notifications from '$components/notifications/Notifications.svelte'
|
||||||
import Header from '$components/Header.svelte'
|
import Header from '$components/Header.svelte'
|
||||||
|
|
||||||
let session: Session = null
|
let session: Session = null
|
||||||
|
|
@ -45,8 +46,9 @@
|
||||||
|
|
||||||
<svelte:window on:resize={setDevice} />
|
<svelte:window on:resize={setDevice} />
|
||||||
|
|
||||||
<div data-theme={$theme} class="h-screen">
|
<div data-theme={$theme} class="min-h-screen">
|
||||||
<Header />
|
<Header />
|
||||||
|
<Notifications />
|
||||||
|
|
||||||
{#if session.error}
|
{#if session.error}
|
||||||
<Toast
|
<Toast
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -4,6 +4,9 @@ import type FileSystem from 'webnative/fs/index'
|
||||||
|
|
||||||
import { loadTheme } from '$lib/theme'
|
import { loadTheme } from '$lib/theme'
|
||||||
import type { Device } from '$lib/device'
|
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 { Session } from '$lib/session'
|
||||||
import type { Theme } from '$lib/theme'
|
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 filesystemStore: Writable<FileSystem | null> = writable(null)
|
||||||
|
|
||||||
export const deviceStore: Writable<Device> = writable({ isMobile: true })
|
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([])
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue