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>,
|
||||
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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { 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
|
||||
|
|
|
|||
|
|
@ -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 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([])
|
||||
|
|
|
|||
Loading…
Reference in New Issue