diff options
Diffstat (limited to 'src/client')
| -rw-r--r-- | src/client/index.html | 138 | ||||
| -rw-r--r-- | src/client/src/albums.js | 87 | ||||
| -rw-r--r-- | src/client/src/app.html | 12 | ||||
| -rw-r--r-- | src/client/src/icons.js | 19 | ||||
| -rw-r--r-- | src/client/src/index.js | 29 | ||||
| -rw-r--r-- | src/client/src/lib/SVGSprite.svelte | 6 | ||||
| -rw-r--r-- | src/client/src/lib/SVGSpriteButton.svelte | 61 | ||||
| -rw-r--r-- | src/client/src/lib/app.css | 22 | ||||
| -rw-r--r-- | src/client/src/lib/connector.ts (renamed from src/client/src/connector.js) | 23 | ||||
| -rw-r--r-- | src/client/src/lib/icons/cached_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/cached_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg) | 0 | ||||
| -rw-r--r-- | src/client/src/lib/icons/camera_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/camera_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg) | 0 | ||||
| -rw-r--r-- | src/client/src/lib/icons/check_circle_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/check_circle_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg) | 0 | ||||
| -rw-r--r-- | src/client/src/lib/icons/deselect_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/deselect_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg) | 0 | ||||
| -rw-r--r-- | src/client/src/lib/icons/download_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/download_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg) | 0 | ||||
| -rw-r--r-- | src/client/src/lib/icons/image_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/image_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg) | 0 | ||||
| -rw-r--r-- | src/client/src/lib/icons/open_in_new_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/open_in_new_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg) | 0 | ||||
| -rw-r--r-- | src/client/src/lib/icons/pause_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/pause_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg) | 0 | ||||
| -rw-r--r-- | src/client/src/lib/icons/photo_album_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/photo_album_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg) | 0 | ||||
| -rw-r--r-- | src/client/src/lib/icons/photo_frame_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/photo_frame_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg) | 0 | ||||
| -rw-r--r-- | src/client/src/lib/icons/play_arrow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/play_arrow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg) | 0 | ||||
| -rwxr-xr-x | src/client/src/lib/icons/removefill.sh (renamed from src/client/src/icons/removefill.sh) | 0 | ||||
| -rw-r--r-- | src/client/src/lib/icons/search_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/search_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg) | 0 | ||||
| -rw-r--r-- | src/client/src/lib/icons/select_all_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/select_all_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg) | 0 | ||||
| -rw-r--r-- | src/client/src/lib/icons/settings_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/settings_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg) | 0 | ||||
| -rw-r--r-- | src/client/src/lib/icons/share_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/share_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg) | 0 | ||||
| -rw-r--r-- | src/client/src/lib/icons/skip_next_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/skip_next_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg) | 0 | ||||
| -rw-r--r-- | src/client/src/lib/icons/skip_previous_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/skip_previous_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg) | 0 | ||||
| -rw-r--r-- | src/client/src/lib/icons/slideshow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/slideshow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg) | 0 | ||||
| -rw-r--r-- | src/client/src/lib/icons/visibility_off_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/visibility_off_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg) | 0 | ||||
| -rw-r--r-- | src/client/src/pages.js | 54 | ||||
| -rw-r--r-- | src/client/src/routes/+layout.svelte | 98 | ||||
| l--------- | src/client/src/routes/+page.svelte | 1 | ||||
| -rw-r--r-- | src/client/src/routes/albums/+page.svelte | 77 | ||||
| -rw-r--r-- | src/client/src/routes/settings/+page.svelte | 103 | ||||
| -rw-r--r-- | src/client/src/routes/slideshow/+page.svelte | 149 | ||||
| -rw-r--r-- | src/client/src/settings.js | 16 | ||||
| -rw-r--r-- | src/client/src/slides.js | 137 | ||||
| -rw-r--r-- | src/client/src/style.css | 48 | ||||
| -rw-r--r-- | src/client/svelte.config.ts | 21 |
39 files changed, 572 insertions, 529 deletions
diff --git a/src/client/index.html b/src/client/index.html deleted file mode 100644 index dcecf89..0000000 --- a/src/client/index.html +++ /dev/null @@ -1,138 +0,0 @@ -<!DOCTYPE HTML> -<html> -<head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> - <script src="/src/index.js" type="module" defer></script> -</head> -<body class="bg-black text-white fill-white h-dvh max-h-screen flex flex-col m-0 overflow-hidden"> - <header></header> - <main class="my-4 overflow-hidden h-full flex flex-col"> - <!-- Slideshow Page --> - <div id="slideshow" class="hidden! overflow-hidden h-full flex flex-col"> - <div class="flex justify-end pe-[10vw] w-full absolute bg-gradient-to-b from-black/40 z-20"> - <div id="slideshow-share" class="z-10"> - <button id="share" class="svg-btn p-3 size-13"><svg alt="Share" class="size-7"><use href="/__spritemap#sprite-share"></use></svg></button> - <button id="download" class="svg-btn p-3 size-13"><svg alt="Download" class="size-7"><use href="/__spritemap#sprite-download"></use></svg></button> - </div> - </div> - <div id="slideshow-carousel" class="h-full"></div> - <template id="carousel-cell-template"> - <div class="carousel-cell"><img class="carousel-img" src="" /></div> - </template> - <div class="grid grid-cols-3 w-full max-w-3xl m-auto justify-items-center py-4"> - <button id="prev-slide" class="svg-btn size-12"><svg alt="Previous Slide" class="size-full"><use href="/__spritemap#sprite-skip_previous"></use></svg></button> - <button id="play-pause" class="svg-btn size-12"><svg alt="Pause" class="size-full"><use href="/__spritemap#sprite-pause"></use></svg></button> - <button id="next-slide" class="svg-btn size-12"><svg alt="Next Slide" class="size-full"><use href="/__spritemap#sprite-skip_next"></use></svg></button> - </div> - </div> - <!-- Albums Page --> - <div id="albums" class="hidden! overflow-y-scroll"><div class="px-4 m-auto max-w-3xl"> - <div class="p-2 sticky top-0 z-20 bg-black"> - <input class="rounded-input mx-auto mb-4 w-full" id="album-search" placeholder="Search your albums" /> - <button id="albums-submit" class="rounded-btn"> - <svg class="size-6"><use href="/__spritemap#sprite-check_circle"></use></svg> - Select - </button> - </div> - <div id="albums-container" class="m-auto z-10"></div> - <template id="album-template"> - <div class="album group"> - <svg class="opacity-0 self-center size-6 group-hover:opacity-50 - group-data-selected:opacity-100 group-data-selected:fill-blue-300" alt="Select Album"> - <use href="/__spritemap#sprite-check_circle"></use> - </svg> - <img class="aspect-square object-cover rounded-2xl group-hover:shadow-md" /> - <div class="flex justify-between self-center text-lg"> - <div> - <span class="album-name font-bold group-hover:text-blue-300"></span> - <div class="album-info"> - <span><span class="album-assets-count"></span> items</span> - <span class="album-shared">• Shared</span> - </div> - </div> - </div> - <a target="_blank" class="opacity-0 group-hover:opacity-100 absolute bottom-4 right-4"> - <svg class="size-6" alt="View album in Immich"><use href="/__spritemap#sprite-open_in_new"></use></svg> - </a> - </div> - </template> - </div></div> - <!-- Settings Page --> - <div id="settings" class="hidden! overflow-y-scroll"> - <form class="flex flex-col gap-4 m-auto max-w-3xl mx-auto px-4"> - <fieldset class="rounded-fieldset"> - <h2 class="fieldset-header"> - <svg class="size-6 inline"><use href="/__spritemap#sprite-camera"></use></svg> - Immich Server - </h2> - <div> - <label class="settings-label">Immich URL</label> - <p>Complete Immich base url (e.g. <span class="font-medium">http://immich.local</span>)</p> - </div> - <input class="rounded-input" name="immich_url" type="text" /> - <div> - <label class="settings-label">Immich API Key</label> - <p>Generate an API key in User Settings</p> - </div> - <input class="rounded-input" name="immich_api_key" type="text" /> - </fieldset> - <fieldset class="rounded-fieldset"> - <h2 class="fieldset-header"> - <svg class="size-6 inline"><use href="/__spritemap#sprite-photo_frame"></use></svg> - Display - </h2> - <div class="grid md:grid-cols-[16fr_9fr] gap-4"> - <div> - <label class="settings-label">Image Duration</label> - <p>Number of seconds each image will be displayed.</p> - </div> - <input class="my-auto rounded-input" name="image_duration" type="number" step="0.1" /> - <div> - <label class="settings-label">Transition Duration</label> - <p>Number of seconds each image transition will take.<br>Set as 0 to disable.</p> - </div> - <input class="my-auto rounded-input" name="transition_duration" type="number" step="0.1" /> - <div> - <label class="settings-label">Max Framerate</label> - <p>Target display framerate.<br>Simple transitions look good at 12-15 fps.</p> - </div> - <input class="my-auto rounded-input" name="max_framerate" type="number" step="0.1" /> - <div> - <label class="settings-label">Display Size</label> - <p>Image size to load on the display.<br>Large thumbnail size is suitable for FHD.</p> - </div> - <select class="my-auto rounded-input" name="display_size"> - <option value="thumbnail">Small Thumbnail (~15 kB)</option> - <option value="preview">Large Thumbnail (~400 kB)</option> - <option value="fullsize">Original Image</option> - </select> - <div> - <label class="settings-label">Max Cached Assets</label> - <p>Number of assets that can exist at once in RAM.<br>Each asset will take ~10x the display size in memory.</p> - </div> - <input class="my-auto rounded-input" name="max_cache_assets" type="number" /> - </div> - </fieldset> - <input id="settings-submit" class="rounded-btn ml-auto" type="submit" value="Save Settings" /> - <br> - <div class="text-white/50 text-right flex flex-col gap-1"> - <p>Immich Pix Frame</p> - <p>© <a class="text-white/70" href="https://tjkeller.xyz">Tim Keller</a> 2025</p> - <p>GPL-3.0 License</p> - <p><a class="text-white/70" href="https://github.com/tjkeller-xyz/immich-pix-frame">View Source</a></p> - </div> - </form> - </div> - </main> - <footer class="w-full"> - <div class="max-w-5xl m-auto"> - <div id="menu" class="flex w-full p-2 gap-4 box-border border-t border-gray-500"> - <a class="nav-btn" href="/slideshow"><svg class="size-6"><use href="/__spritemap#sprite-slideshow" ></use></svg><span class="max-[425px]:hidden">Slideshow</span></a> - <a class="nav-btn" href="/albums" ><svg class="size-6"><use href="/__spritemap#sprite-photo_album"></use></svg><span class="max-[425px]:hidden">Albums</span></a> - <a class="nav-btn" href="/settings" ><svg class="size-6"><use href="/__spritemap#sprite-settings" ></use></svg><span class="max-[425px]:hidden">Settings</span></a> - </div> - </div> - </footer> -</body> -</html> diff --git a/src/client/src/albums.js b/src/client/src/albums.js deleted file mode 100644 index 0ac9195..0000000 --- a/src/client/src/albums.js +++ /dev/null @@ -1,87 +0,0 @@ -import apiConnector from "./connector.js" - -class Album { - static albums = [] - static albumTemplate = null - static albumContainer = null - - constructor(data) { - this.data = data - // create clone element - const e = Album.albumTemplate.content.cloneNode(true) - e.firstElementChild.dataset.key = data.id - e.querySelector("a").href = apiConnector.albumSrc(data.id) - e.querySelector("img").src = apiConnector.assetThumbnailSrc(data.albumThumbnailAssetId) - e.querySelector(".album-name").textContent = data.albumName - e.querySelector(".album-assets-count").textContent = data.assetCount.toLocaleString() - if (!data.shared) - e.querySelector(".album-shared").remove() - - Album.albums.push(this) - Album.albumsContainer.appendChild(e) - this.element = Album.albumsContainer.lastElementChild - if (data.selected) - this.element.dataset.selected = "1" - } - - toggleVisibility(visible) { this.element.classList.toggle("hidden!", !visible) } - - static albumSelect(e) { - // find album element - let album = e.target - while (album && !album.classList.contains("album")) - album = album.parentElement - - if (album === null) - return - - if (album.dataset.selected) - delete album.dataset.selected - else - album.dataset.selected = "1" - } - - static albumsFilter(e) { - const q = e.target.value.toLowerCase() - for (const album of Album.albums) { - const match = album.data.albumName.toLowerCase().includes(q) - album.toggleVisibility(match) - } - } - - static getSelected() { - const s = [] - for (const album of Album.albums) - if (album.element.dataset.selected) - s.push(album.data.id) - return s - } - - static submitSelected() { - apiConnector.updateAlbums(Album.getSelected()) - } - - static async initAlbums(albumsPageContainer) { - Album.albumsContainer = albumsPageContainer.querySelector("#albums-container") - Album.albumTemplate = albumsPageContainer.querySelector("#album-template") - const albumSearch = albumsPageContainer.querySelector("#album-search") - const albumsSubmit = albumsPageContainer.querySelector("#albums-submit") - - // create albums - const albumsResponse = await apiConnector.fetchAlbums() - if (!albumsResponse.length) - return false - - for (const res of albumsResponse) - new Album(res) - - // album selection - Album.albumsContainer.addEventListener("click", Album.albumSelect) - albumSearch.addEventListener("input", Album.albumsFilter) - albumsSubmit.addEventListener("click", Album.submitSelected) - - return true - } -} - -export default Album.initAlbums diff --git a/src/client/src/app.html b/src/client/src/app.html new file mode 100644 index 0000000..b1faee9 --- /dev/null +++ b/src/client/src/app.html @@ -0,0 +1,12 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + %sveltekit.head% + <link rel="manifest" href="%sveltekit.assets%/manifest.json" /> + </head> + <body data-sveltekit-preload-data="hover"> + <div style="display: contents">%sveltekit.body%</div> + </body> +</html> diff --git a/src/client/src/icons.js b/src/client/src/icons.js deleted file mode 100644 index 529d509..0000000 --- a/src/client/src/icons.js +++ /dev/null @@ -1,19 +0,0 @@ -import "./icons/cached_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg" -import "./icons/camera_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg" -import "./icons/check_circle_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg" -import "./icons/deselect_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg" -import "./icons/download_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg" -import "./icons/image_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg" -import "./icons/open_in_new_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg" -import "./icons/pause_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg" -import "./icons/photo_album_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg" -import "./icons/photo_frame_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg" -import "./icons/play_arrow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg" -import "./icons/search_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg" -import "./icons/select_all_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg" -import "./icons/settings_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg" -import "./icons/share_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg" -import "./icons/skip_next_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg" -import "./icons/skip_previous_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg" -import "./icons/slideshow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg" -import "./icons/visibility_off_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg" diff --git a/src/client/src/index.js b/src/client/src/index.js deleted file mode 100644 index 7d7c9db..0000000 --- a/src/client/src/index.js +++ /dev/null @@ -1,29 +0,0 @@ -import "@fontsource/overpass" -import "@fontsource/overpass/700.css" -import "@fontsource/overpass/500.css" -import "./style.css" -import Page from "./pages.js" -import "./icons.js" -import initSlides from "./slides.js" -import initAlbums from "./albums.js" -import initSettings from "./settings.js" - -const slideshow = new Page(document.querySelector("#slideshow"), ["", "/slideshow"], initSlides) -const albums = new Page(document.querySelector("#albums"), ["/albums"], initAlbums) -const settings = new Page(document.querySelector("#settings"), ["/settings"], initSettings) - -window.addEventListener("popstate", Page.pathnameCallback) -Page.pathnameCallback() /* run after all pages are registered */ - -/* add event listeners for anchor elements in footer */ -function softRedirect(e) { - e.preventDefault() - let a = e.target - if (a === null) return - while (a !== null && a.tagName !== "A") - a = a.parentElement - if (a === null) return - Page.softRedirect(a.href) -} - -document.querySelector("#menu").addEventListener("click", softRedirect) diff --git a/src/client/src/lib/SVGSprite.svelte b/src/client/src/lib/SVGSprite.svelte new file mode 100644 index 0000000..ff965c7 --- /dev/null +++ b/src/client/src/lib/SVGSprite.svelte @@ -0,0 +1,6 @@ +<script lang="ts"> + let { sprite, ...rest }: { sprite: string } = $props() + const href = $derived(`/__spritemap#sprite-${sprite}`) +</script> + +<svg {...rest}><use {href}></use></svg> diff --git a/src/client/src/lib/SVGSpriteButton.svelte b/src/client/src/lib/SVGSpriteButton.svelte new file mode 100644 index 0000000..a9c9fcb --- /dev/null +++ b/src/client/src/lib/SVGSpriteButton.svelte @@ -0,0 +1,61 @@ +<script lang="ts"> + import SVGSprite from "$lib/SVGSprite.svelte" + + let { + sprite, + label, + href, + alt, + innerClass, + ...rest + }: { + sprite: string + label?: string + href?: string + alt?: string + innerClass?: string + } = $props() +</script> + +<style> + @import "tailwindcss"; + + .selected-button, [aria-current=page] { + @apply text-blue-300 fill-blue-300 bg-gray-900; + } + /* TODO + [aria-current=page]::before { + view-transition-name: active-page; + } + */ +</style> + +{#snippet inner()} + + <SVGSprite class={innerClass ?? "size-full"} {sprite} {alt}></SVGSprite> + {#if label} + <span class="max-[425px]:hidden">{label}</span> + {/if} +{/snippet} + +{#if href} + <a + {...rest} + class={[ + "flex items-center justify-center no-underline w-full gap-4 p-3", + "relative rounded-full hover:text-blue-300 hover:fill-blue-300", + // @ts-ignore + rest.class + ]} + {href} + > + {@render inner()} + </a> +{:else} + <button + class="bg-none border-0 p-0 cursor-pointer relative" + {...rest} + > + {@render inner()} + </button> +{/if} diff --git a/src/client/src/lib/app.css b/src/client/src/lib/app.css new file mode 100644 index 0000000..b88882b --- /dev/null +++ b/src/client/src/lib/app.css @@ -0,0 +1,22 @@ +@import "tailwindcss"; + +:root { + --font-sans: "Overpass", sans-serif; + @apply scheme-dark +} + +.rounded-fieldset { + @apply border border-gray-500 rounded-2xl p-6 flex flex-col gap-4 +} +.fieldset-header { + @apply font-bold text-blue-300 fill-blue-300 text-xl +} +.settings-label { + @apply font-medium text-blue-200 +} +.rounded-input { + @apply rounded-2xl bg-zinc-800 p-4 +} +.rounded-btn { + @apply rounded-full w-fit bg-blue-300 px-4 py-2 text-black fill-black font-medium cursor-pointer flex gap-1 +} diff --git a/src/client/src/connector.js b/src/client/src/lib/connector.ts index 9568dc8..0224438 100644 --- a/src/client/src/connector.js +++ b/src/client/src/lib/connector.ts @@ -1,5 +1,26 @@ import io from "socket.io-client" +export interface Album { + id: string + albumThumbnailAssetId: string + albumName: string + assetCount: number + shared: boolean + selected: boolean +} + +export interface Config { + immich_url: string + immich_api_key: string + image_duration: number + transition_duration: number + max_framerate: number + auto_transition: boolean + display_size: "fullsize" | "preview" | "thumbnail" + max_cache_assets: number + album_list?: string[] +} + class APIConnector { constructor(url) { this.url = url ?? "" @@ -88,5 +109,5 @@ class APIConnector { assetFileName(key) { return this.fetch(`/asset/${key}/filename`).then(d => d.filename) } } -const apiConnector = new APIConnector(window.location.origin) +const apiConnector = new APIConnector("http://192.168.1.182:8080/") export default apiConnector diff --git a/src/client/src/icons/cached_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/cached_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg index 5e389dc..5e389dc 100644 --- a/src/client/src/icons/cached_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +++ b/src/client/src/lib/icons/cached_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg diff --git a/src/client/src/icons/camera_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/camera_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg index 06d30f0..06d30f0 100644 --- a/src/client/src/icons/camera_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg +++ b/src/client/src/lib/icons/camera_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg diff --git a/src/client/src/icons/check_circle_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/check_circle_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg index 028526b..028526b 100644 --- a/src/client/src/icons/check_circle_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg +++ b/src/client/src/lib/icons/check_circle_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg diff --git a/src/client/src/icons/deselect_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/deselect_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg index dd46e14..dd46e14 100644 --- a/src/client/src/icons/deselect_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +++ b/src/client/src/lib/icons/deselect_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg diff --git a/src/client/src/icons/download_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/download_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg index e9ef3c9..e9ef3c9 100644 --- a/src/client/src/icons/download_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg +++ b/src/client/src/lib/icons/download_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg diff --git a/src/client/src/icons/image_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/image_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg index 1ff79ee..1ff79ee 100644 --- a/src/client/src/icons/image_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg +++ b/src/client/src/lib/icons/image_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg diff --git a/src/client/src/icons/open_in_new_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/open_in_new_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg index 12e0802..12e0802 100644 --- a/src/client/src/icons/open_in_new_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +++ b/src/client/src/lib/icons/open_in_new_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg diff --git a/src/client/src/icons/pause_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/pause_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg index aa7aece..aa7aece 100644 --- a/src/client/src/icons/pause_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg +++ b/src/client/src/lib/icons/pause_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg diff --git a/src/client/src/icons/photo_album_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/photo_album_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg index 95ead34..95ead34 100644 --- a/src/client/src/icons/photo_album_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg +++ b/src/client/src/lib/icons/photo_album_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg diff --git a/src/client/src/icons/photo_frame_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/photo_frame_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg index e26dccc..e26dccc 100644 --- a/src/client/src/icons/photo_frame_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg +++ b/src/client/src/lib/icons/photo_frame_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg diff --git a/src/client/src/icons/play_arrow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/play_arrow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg index 22dd0af..22dd0af 100644 --- a/src/client/src/icons/play_arrow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg +++ b/src/client/src/lib/icons/play_arrow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg diff --git a/src/client/src/icons/removefill.sh b/src/client/src/lib/icons/removefill.sh index 399e8e4..399e8e4 100755 --- a/src/client/src/icons/removefill.sh +++ b/src/client/src/lib/icons/removefill.sh diff --git a/src/client/src/icons/search_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/search_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg index 1d95298..1d95298 100644 --- a/src/client/src/icons/search_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +++ b/src/client/src/lib/icons/search_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg diff --git a/src/client/src/icons/select_all_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/select_all_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg index 3a6618e..3a6618e 100644 --- a/src/client/src/icons/select_all_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +++ b/src/client/src/lib/icons/select_all_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg diff --git a/src/client/src/icons/settings_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/settings_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg index 92240e4..92240e4 100644 --- a/src/client/src/icons/settings_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg +++ b/src/client/src/lib/icons/settings_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg diff --git a/src/client/src/icons/share_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/share_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg index 911d24e..911d24e 100644 --- a/src/client/src/icons/share_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg +++ b/src/client/src/lib/icons/share_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg diff --git a/src/client/src/icons/skip_next_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/skip_next_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg index ba0282f..ba0282f 100644 --- a/src/client/src/icons/skip_next_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg +++ b/src/client/src/lib/icons/skip_next_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg diff --git a/src/client/src/icons/skip_previous_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/skip_previous_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg index ea45ca0..ea45ca0 100644 --- a/src/client/src/icons/skip_previous_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg +++ b/src/client/src/lib/icons/skip_previous_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg diff --git a/src/client/src/icons/slideshow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/slideshow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg index 17fb6dd..17fb6dd 100644 --- a/src/client/src/icons/slideshow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg +++ b/src/client/src/lib/icons/slideshow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg diff --git a/src/client/src/icons/visibility_off_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/visibility_off_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg index 41b5681..41b5681 100644 --- a/src/client/src/icons/visibility_off_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg +++ b/src/client/src/lib/icons/visibility_off_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg diff --git a/src/client/src/pages.js b/src/client/src/pages.js deleted file mode 100644 index e2199a4..0000000 --- a/src/client/src/pages.js +++ /dev/null @@ -1,54 +0,0 @@ -const menu = document.querySelector("#menu") - -export default class Page { - static pages = {} - static currentPage = null - - static pathnameCallback() { - const path = window.location.pathname.replace(/\/$/, "") - const page = Page.pages[path] - - if (!page) - throw new Error(`Path '${path}' does not exist`) - - if (Page.currentPage) - Page.currentPage.setVisible(false) - - page.setVisible(true) - Page.currentPage = page - } - - static softRedirect(path) { - window.history.pushState({}, "", path) - Page.pathnameCallback() - } - - constructor(pageContainer, endpoints, f_initialize) { - for (const endpoint of endpoints) - Page.pages[endpoint] = this - - this.pageContainer = pageContainer - this.endpoints = endpoints - this.initialize = f_initialize - this.visible = false - this.initialized = false - } - - async setVisible(visible) { - this.pageContainer.classList.toggle("hidden!", !visible) - this.visible = visible - if (this.visible) { - /* initialize page */ - if (!this.initialized && this.initialize) - this.initialized = await this.initialize(this.pageContainer) - - /* set selected attribute on the link */ - for (const a of menu.querySelectorAll("a")) { - if (this.endpoints.includes(a.getAttribute("href"))) - a.dataset.selected = "1" - else - delete a.dataset.selected - } - } - } -} diff --git a/src/client/src/routes/+layout.svelte b/src/client/src/routes/+layout.svelte new file mode 100644 index 0000000..d963f02 --- /dev/null +++ b/src/client/src/routes/+layout.svelte @@ -0,0 +1,98 @@ +<script lang="ts"> + import { onNavigate } from "$app/navigation" + import "@fontsource/overpass" + import "@fontsource/overpass/700.css" + import "@fontsource/overpass/500.css" + import "$lib/app.css" + import SVGSpriteButton from "$lib/SVGSpriteButton.svelte" + + let { children } = $props() + + /* Endpoints */ + const endpoints = [ + { label: "Slideshow", sprite: "slideshow", href: "#/slideshow", isDefault: true }, + { label: "Albums", sprite: "photo_album", href: "#/albums" }, + { label: "Settings", sprite: "settings", href: "#/settings" }, + ] + + /* View transition */ + let swipeTransitionReversed: boolean = $state(false) + let hashPath: string = $state(window.location.hash) + onNavigate((navigation: any) => { + const oldHashPath = hashPath + hashPath = window.location.hash + + /* Check if browser supports view transitions api and whether an actual navigation has occurred */ + if (!document.startViewTransition || oldHashPath == hashPath) + return + + const orderedHashPaths = endpoints.map(e => e.href) + swipeTransitionReversed = orderedHashPaths.indexOf(oldHashPath) > orderedHashPaths.indexOf(hashPath) + + /* Begin view transition */ + return new Promise((resolve: Function) => { + document.startViewTransition(async () => { + resolve() + await navigation.complete + }) + }) + }) +</script> + +<svelte:head> + <!-- <link rel="icon" href={favicon} /> --> +</svelte:head> + +<style> + @keyframes slide-from-right { from { transform: translateX(100dvw) } } + @keyframes slide-from-left { from { transform: translateX(-100dvw) } } + @keyframes slide-to-right { to { transform: translateX(100dvw) } } + @keyframes slide-to-left { to { transform: translateX(-100dvw) } } + + footer { view-transition-name: navbar } + + @media (prefers-reduced-motion: no-preference) { + :root::view-transition-old(root) { + animation: 300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left; + } + :root::view-transition-new(root) { + animation: 300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right; + } + :root:has(.swipe-transition-reversed)::view-transition-old(root) { + animation: 300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-right; + } + :root:has(.swipe-transition-reversed)::view-transition-new(root) { + animation: 300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-left; + } + } +</style> + +<div + class="bg-black text-white fill-white h-dvh max-h-screen flex flex-col m-0 overflow-hidden" + class:swipe-transition-reversed={swipeTransitionReversed} +> + <header></header> + <main class="my-4 overflow-hidden h-full flex flex-col"> + {@render children?.()} + </main> + <!-- Bottom nav bar --> + <footer class="w-full"> + <div class="max-w-5xl m-auto"> + <div id="menu" class="flex w-full p-2 gap-4 box-border border-t border-gray-500"> + {#snippet navButton(label: string, sprite: string, href: string, isDefault: boolean = false)} + {@const selected = href == hashPath || (isDefault && !window.location.hash)} + <SVGSpriteButton + {label} + {sprite} + {href} + innerClass="size-6" + aria-current={selected && "page"} + ></SVGSpriteButton> + {/snippet} + {#each endpoints as e} + {@render navButton(e.label, e.sprite, e.href, e.isDefault)} + {/each} + </div> + </div> + </footer> +</div> diff --git a/src/client/src/routes/+page.svelte b/src/client/src/routes/+page.svelte new file mode 120000 index 0000000..ff71e47 --- /dev/null +++ b/src/client/src/routes/+page.svelte @@ -0,0 +1 @@ +slideshow/+page.svelte
\ No newline at end of file diff --git a/src/client/src/routes/albums/+page.svelte b/src/client/src/routes/albums/+page.svelte new file mode 100644 index 0000000..9534307 --- /dev/null +++ b/src/client/src/routes/albums/+page.svelte @@ -0,0 +1,77 @@ +<script lang="ts"> + import { onMount } from "svelte" + import SVGSprite from "$lib/SVGSprite.svelte" + import apiConnector, { type Album } from "$lib/connector" + + let albums: Album[] = $state([]) + //let filteredAlbums: Album[] = $derived() + + function updateAlbums() { + apiConnector.updateAlbums(albums.filter((a: Album) => a.selected).map((a: Album) => a.id)) + } + + onMount(async () => { + albums = await apiConnector.fetchAlbums() + }) +</script> + +<div class="overflow-y-scroll"> + <div class="px-4 m-auto max-w-3xl"> + <!-- Header search bar --> + <div class="p-2 sticky top-0 z-20 bg-black"> + <input class="rounded-input mx-auto mb-4 w-full" placeholder="Search your albums" /> + <button + class="rounded-btn" + onclick={updateAlbums} + > + <SVGSprite sprite="check_circle" class="size-6"></SVGSprite> + Select + </button> + </div> + <!-- Albums --> + <div class="m-auto z-10"> + {#each albums as album} + <button + class={[ + "flex relative h-40 w-full gap-6 p-3 cursor-pointer border-y border-slate-800 text-left group", + album.selected ? "bg-slate-950 hover:bg-slare-900" : "hover:bg-gray-900", + ]} + onclick={() => album.selected = !album.selected} + > + <SVGSprite + alt="Select Album" + sprite="check_circle" + class={[ + "opacity-0 self-center size-6", + album.selected ? "opacity-100 fill-blue-300" : "group-hover:opacity-50", + ]} + > + </SVGSprite> + <img + alt="" + src={apiConnector.assetThumbnailSrc(album.albumThumbnailAssetId)} + class="aspect-square object-cover rounded-2xl group-hover:shadow-md" + /> + <div class="flex justify-between self-center text-lg"> + <div> + <span class="font-bold group-hover:text-blue-300">{album.albumName}</span> + <div> + <span>{album.assetCount} items</span> + {#if album.shared} + <span>• Shared</span> + {/if} + </div> + </div> + </div> + <a + href={apiConnector.albumSrc(album.id)} + target="_blank" + class="opacity-0 group-hover:opacity-100 absolute bottom-4 right-4" + > + <SVGSprite alt="View album in Immich" sprite="open_in_new" class="size-6"></SVGSprite> + </a> + </button> + {/each} + </div> + </div> +</div> diff --git a/src/client/src/routes/settings/+page.svelte b/src/client/src/routes/settings/+page.svelte new file mode 100644 index 0000000..a0483a0 --- /dev/null +++ b/src/client/src/routes/settings/+page.svelte @@ -0,0 +1,103 @@ +<script lang="ts"> + import apiConnector, { type Config } from "$lib/connector" + import { onMount } from "svelte" + + let immich_url: string = $state() + let immich_api_key: string = $state() + let image_duration: number = $state() + let transition_duration: number = $state() + let max_framerate: number = $state() + let display_size: string = $state() + let max_cache_assets: number = $state() + + function onsubmit(e: SubmitEvent) { + e.preventDefault() + const config: Config = { + immich_url, + immich_api_key, + image_duration, + transition_duration, + max_framerate, + display_size, + max_cache_assets, + } + apiConnector.updateConfig(config) + } + + onMount(async () => { + const currentConfig: Config = await apiConnector.fetchConfig() + ;({ + immich_url, immich_api_key, image_duration, transition_duration, + max_framerate, display_size, max_cache_assets, + } = currentConfig) + }) +</script> + +<div class="overflow-y-scroll"> + <form + class="flex flex-col gap-4 m-auto max-w-3xl mx-auto px-4" + {onsubmit} + > + <fieldset class="rounded-fieldset"> + <h2 class="fieldset-header"> + <svg class="size-6 inline"><use href="/__spritemap#sprite-camera"></use></svg> + Immich Server + </h2> + <div> + <label for="immich_url" class="settings-label">Immich URL</label> + <p>Complete Immich base url (e.g. <span class="font-medium">http://immich.local</span>)</p> + </div> + <input class="rounded-input" id="immich_url" type="text" bind:value={immich_url} /> + <div> + <label for="immich_api_key" class="settings-label">Immich API Key</label> + <p>Generate an API key in User Settings</p> + </div> + <input class="rounded-input" id="immich_api_key" type="text" bind:value={immich_api_key} /> + </fieldset> + <fieldset class="rounded-fieldset"> + <h2 class="fieldset-header"> + <svg class="size-6 inline"><use href="/__spritemap#sprite-photo_frame"></use></svg> + Display + </h2> + <div class="grid md:grid-cols-[16fr_9fr] gap-4"> + <div> + <label for="image_duration" class="settings-label">Image Duration</label> + <p>Number of seconds each image will be displayed.</p> + </div> + <input class="my-auto rounded-input" id="image_duration" type="number" step="0.1" bind:value={image_duration} /> + <div> + <label for="transition_duration" class="settings-label">Transition Duration</label> + <p>Number of seconds each image transition will take.<br>Set as 0 to disable.</p> + </div> + <input class="my-auto rounded-input" id="transition_duration" type="number" step="0.1" bind:value={transition_duration} /> + <div> + <label for="max_framerate" class="settings-label">Max Framerate</label> + <p>Target display framerate.<br>Simple transitions look good at 12-15 fps.</p> + </div> + <input class="my-auto rounded-input" id="max_framerate" type="number" step="0.1" bind:value={max_framerate} /> + <div> + <label for="display_size" class="settings-label">Display Size</label> + <p>Image size to load on the display.<br>Large thumbnail size is suitable for FHD.</p> + </div> + <select class="my-auto rounded-input" id="display_size" bind:value={display_size}> + <option value="thumbnail">Small Thumbnail (~15 kB)</option> + <option value="preview">Large Thumbnail (~400 kB)</option> + <option value="fullsize">Original Image</option> + </select> + <div> + <label for="max_cache_assets" class="settings-label">Max Cached Assets</label> + <p>Number of assets that can exist at once in RAM.<br>Each asset will take ~10x the display size in memory.</p> + </div> + <input class="my-auto rounded-input" id="max_cache_assets" type="number" bind:value={max_cache_assets} /> + </div> + </fieldset> + <input id="settings-submit" class="rounded-btn ml-auto" type="submit" value="Save Settings" /> + <br> + <div class="text-white/50 text-right flex flex-col gap-1"> + <p>Immich Pix Frame</p> + <p>© <a class="text-white/70" href="https://tjkeller.xyz">Tim Keller</a> 2025</p> + <p>GPL-3.0 License</p> + <p><a class="text-white/70" href="https://github.com/tjkeller-xyz/immich-pix-frame">View Source</a></p> + </div> + </form> +</div> diff --git a/src/client/src/routes/slideshow/+page.svelte b/src/client/src/routes/slideshow/+page.svelte new file mode 100644 index 0000000..df2d352 --- /dev/null +++ b/src/client/src/routes/slideshow/+page.svelte @@ -0,0 +1,149 @@ +<script lang="ts"> + import { onMount } from "svelte" + import type { Attachment } from "svelte/attachments" + import SVGSpriteButton from "$lib/SVGSpriteButton.svelte" + import Flickity from "flickity" + import "flickity/dist/flickity.min.css" + import apiConnector from "$lib/connector" + + let selectedIndex: number + let assetIndex: number + let assetSrcs: string[] = $state(new Array(11).map(() => undefined)) /* 11 cells for carousel */ + + /* Initialize carousel */ + let carousel: HTMLDivElement + let flickity: Flickity + + /* Change image function */ + function seek() { + if (!flickity) + return + // This is just like calculating movement in lazycachelist.py + // Gets the min of the absolute values and returns signed value + const increment = [ + flickity.selectedIndex - selectedIndex, // No list wrap + flickity.selectedIndex - flickity.cells.length, // Wrap backwards (0 -> -1) + flickity.cells.length - selectedIndex, // Wrap forwards (-1 -> 0) + ].reduce((key, v) => Math.abs(v) < Math.abs(key) ? v : key) + selectedIndex = flickity.selectedIndex + assetIndex += increment + apiConnector.seek(increment) + } + + /* Socket callback for seek signal -- load images */ + apiConnector.seekCallbacks.push(() => { + if (!flickity) + return + if (assetIndex !== apiConnector.assetIndex) { + assetIndex = apiConnector.assetIndex + let i = apiConnector.movement + for (; i > 0; i--) flickity.next() + for (; i < 0; i++) flickity.previous() + selectedIndex = flickity.selectedIndex + } + + // Load new assets + for (let i = 0; i < flickity.cells.length; i++) { + const x = (i + selectedIndex + 6) % flickity.cells.length + assetSrcs[x] = apiConnector.assetPreviewSrc(apiConnector.assets[i]) + } + }) + + /* Make sure images have correct margin when loaded since scroll function + * depends on them being loaded */ + function positionImageStatic(img: HTMLImageElement) { + if (!flickity) + return + const i = parseInt(img.dataset.index) + if (i == flickity.selectedIndex) + img.style.marginLeft = (img.parentElement.clientWidth - img.clientWidth) / 2 + "px" + else if ((i + 1) % flickity.cells.length == flickity.selectedIndex) + img.style.marginLeft = img.parentElement.clientWidth - img.clientWidth + "px" + } + + const attachImg: Attachment = (img: HTMLImageElement) => { + img.addEventListener("load", () => positionImageStatic(img)) + if (img.complete) + positionImageStatic(img) + } + + onMount(() => { + flickity = new Flickity(carousel, { + wrapAround: true, + prevNextButtons: false, + pageDots: false, + resize: true, + setGallerySize: false, + }) + + /* Flickity function for scrolling to ensure next and prev pics are always + * visible and to transition between states */ + flickity.on("scroll", progress => { + const normalizedProgress: number = progress / (1 / (flickity.cells.length-1)) + const liveSelectedIndex: number = Math.round(normalizedProgress) + const localizedProgress: number = normalizedProgress - liveSelectedIndex + + const prevSelectedCell: HTMLElement = (flickity.cells.at((liveSelectedIndex-1) % flickity.cells.length) as any).element + const liveSelectedCell: HTMLElement = (flickity.cells.at((liveSelectedIndex ) % flickity.cells.length) as any).element + const nextSelectedCell: HTMLElement = (flickity.cells.at((liveSelectedIndex+1) % flickity.cells.length) as any).element + + const prevSelectedImage: HTMLImageElement = prevSelectedCell.firstElementChild as HTMLImageElement + const liveSelectedImage: HTMLImageElement = liveSelectedCell.firstElementChild as HTMLImageElement + const nextSelectedImage: HTMLImageElement = nextSelectedCell.firstElementChild as HTMLImageElement + + const prevMargin: number = prevSelectedCell.clientWidth - prevSelectedImage.clientWidth + const liveMargin: number = liveSelectedCell.clientWidth - liveSelectedImage.clientWidth + const nextMargin: number = nextSelectedCell.clientWidth - nextSelectedImage.clientWidth + + liveSelectedImage.style.marginLeft = liveMargin / 2 + "px" + nextSelectedImage.style.marginLeft = Math.max(0, Math.min(nextMargin, nextMargin * (localizedProgress))) + "px" + prevSelectedImage.style.marginLeft = prevMargin - Math.max(0, Math.min(prevMargin, prevMargin * localizedProgress * -1)) + "px" // TODO clean this + }) + + /* Jump to clicked on slide */ + flickity.on("staticClick", (e, pointer, cellElement, cellIndex) => { + flickity.select(cellIndex) + seek() + }) + + /* Seek whenever a touch event ends */ + flickity.on("dragEnd", seek) + }) + + /* Share */ + function shareCurrent() { + + } + + function downloadCurrent() { + + } +</script> + +<div class="overflow-hidden h-full flex flex-col"> + <!-- Header share buttons --> + <div class="flex justify-end pe-[10vw] w-full absolute bg-linear-to-b from-black/40 z-20"> + <div class="z-10"> + <SVGSpriteButton alt="Share" sprite="share" class="p-3 size-13" onclick={shareCurrent}></SVGSpriteButton> + <SVGSpriteButton alt="Download" sprite="download" class="p-3 size-13" onclick={downloadCurrent}></SVGSpriteButton> + </div> + </div> + <!-- Carousel --> + <div class="h-full" bind:this={carousel}> + {#each assetSrcs as src} + <div class="h-full w-[70vw] mx-3 md:w-[80vw] md:mx-6"> + <img alt="" class="align-middle h-full max-w-full object-contain" {src} {@attach attachImg} /> + </div> + {/each} + </div> + <!-- Media controls --> + <div class="grid grid-cols-3 w-full max-w-3xl m-auto justify-items-center py-4"> + <SVGSpriteButton alt="Previous Slide" sprite="skip_previous" class="size-12" onclick={() => { flickity.previous(); seek() }}></SVGSpriteButton> + {#if false} + <SVGSpriteButton alt="Pause" sprite="pause" class="size-12" onclick={() => {}}></SVGSpriteButton> + {:else} + <SVGSpriteButton alt="Pause" sprite="pause" class="size-12" onclick={() => {}}></SVGSpriteButton> + {/if} + <SVGSpriteButton alt="Next Slide" sprite="skip_next" class="size-12" onclick={() => { flickity.next(); seek() }}></SVGSpriteButton> + </div> +</div> diff --git a/src/client/src/settings.js b/src/client/src/settings.js deleted file mode 100644 index fd8bdad..0000000 --- a/src/client/src/settings.js +++ /dev/null @@ -1,16 +0,0 @@ -import apiConnector from "./connector.js" - -export default async function initSettings(settingsPageContainer) { - const inputList = Array.from(settingsPageContainer.querySelectorAll("[name]")) - const inputs = Object.fromEntries(inputList.map(e => [e.name, e])) - const currentConfig = await apiConnector.fetchConfig() - - for (const [name, value] of Object.entries(currentConfig)) - if (inputs[name]) - inputs[name].value = value - - settingsPageContainer.querySelector("#settings-submit").addEventListener("click", e => { - e.preventDefault() - apiConnector.updateConfig(Object.fromEntries(inputList.map(el => [el.name, el.type === "number" ? parseFloat(el.value) : el.value]))) - }) -} diff --git a/src/client/src/slides.js b/src/client/src/slides.js deleted file mode 100644 index a0d58ac..0000000 --- a/src/client/src/slides.js +++ /dev/null @@ -1,137 +0,0 @@ -import Flickity from "flickity" -import "flickity/dist/flickity.min.css" -import apiConnector from "./connector.js" - -class Slides { - constructor(slidesContainer) { - /* previous selected index */ - this.selectedIndex = 0 - this.assetIndex = 0 - - this.slidesContainer = slidesContainer - - /* append 11 cells to carousel */ - const carousel = this.slidesContainer.querySelector("#slideshow-carousel") - const cellTemplate = this.slidesContainer.querySelector("#carousel-cell-template") - this.cells = [] - for (let i = 0; i < 11; i++) - carousel.appendChild(cellTemplate.content.cloneNode(true)) - - /* initialize slides */ - this.flickity = new Flickity(carousel, { - wrapAround: true, - prevNextButtons: false, - pageDots: false, - resize: true, - setGallerySize: false, - }) - - this.flickity.on("scroll", progress => { this.scroll(progress) }) - this.flickity.on("staticClick", (e, pointer, cellElement, cellIndex) => { this.staticClick(e, pointer, cellElement, cellIndex) }) - this.flickity.on("dragEnd", () => { this.seek() }) - this.initImages() - - /* initialize seek buttons */ - const seekPrevButton = this.slidesContainer.querySelector("#prev-slide") - const seekNextButton = this.slidesContainer.querySelector("#next-slide") - - seekPrevButton.addEventListener("click", () => { this.flickity.previous() ; this.seek() }) - seekNextButton.addEventListener("click", () => { this.flickity.next() ; this.seek() }) - - /* initialize seek callback */ - apiConnector.seekCallbacks.push(c => { this.seekCallback() }) - - /* initialize top controls */ - const assetDownloadButton = this.slidesContainer.querySelector("#download") - - assetDownloadButton.addEventListener("click", () => { apiConnector.assetDownload(apiConnector.currentAsset) }) - } - - seek() { - // this is just like calculating movement in lazycachelist.py - // gets the min of the absolute values and returns signed value - const increment = [ - this.flickity.selectedIndex - this.selectedIndex, // no list wrap - this.flickity.selectedIndex - this.flickity.cells.length, // wrap backwards (0 -> -1) - this.flickity.cells.length - this.selectedIndex, // wrap forwards (-1 -> 0) - ].reduce((key, v) => Math.abs(v) < Math.abs(key) ? v : key) - this.selectedIndex = this.flickity.selectedIndex - this.assetIndex += increment - apiConnector.seek(increment) - } - - seekCallback() { - let i - if (this.assetIndex !== apiConnector.assetIndex) { - this.assetIndex = apiConnector.assetIndex - i = apiConnector.movement - - for (; i > 0; i--) this.flickity.next() - for (; i < 0; i++) this.flickity.previous() - this.selectedIndex = this.flickity.selectedIndex - } - - // load new imgs - // TODO need to make the 11 cells a constant somehow - for (i = 0; i < this.flickity.cells.length; i++) { - const x = (i + this.selectedIndex + 6) % this.flickity.cells.length - const e = this.flickity.cells[x].element - const img = e.firstElementChild - img.src = apiConnector.assetPreviewSrc(apiConnector.assets[i]) - } - } - - /* Flickity function for scrolling to ensure next and prev pics are always - * visible and to transition between states */ - scroll(progress) { - const normalizedProgress = progress / (1 / (this.flickity.cells.length-1)) - const liveSelectedIndex = Math.round(normalizedProgress) - const localizedProgress = normalizedProgress - liveSelectedIndex - - const prevSelectedCell = this.flickity.cells.at((liveSelectedIndex-1) % this.flickity.cells.length).element - const liveSelectedCell = this.flickity.cells.at((liveSelectedIndex ) % this.flickity.cells.length).element - const nextSelectedCell = this.flickity.cells.at((liveSelectedIndex+1) % this.flickity.cells.length).element - - const prevSelectedImage = prevSelectedCell.firstElementChild - const liveSelectedImage = liveSelectedCell.firstElementChild - const nextSelectedImage = nextSelectedCell.firstElementChild - - const prevMargin = prevSelectedCell.clientWidth - prevSelectedImage.clientWidth - const liveMargin = liveSelectedCell.clientWidth - liveSelectedImage.clientWidth - const nextMargin = nextSelectedCell.clientWidth - nextSelectedImage.clientWidth - - liveSelectedImage.style.marginLeft = liveMargin / 2 + "px" - nextSelectedImage.style.marginLeft = Math.max(0, Math.min(nextMargin, nextMargin * (localizedProgress))) + "px" - prevSelectedImage.style.marginLeft = prevMargin - Math.max(0, Math.min(prevMargin, prevMargin * localizedProgress * -1)) + "px" // TODO clean this - } - - /* jump to clicked on slide */ - staticClick(e, pointer, cellElement, cellIndex) { - this.flickity.select(cellIndex) - this.seek() - } - - /* make sure images have correct margin when loaded since scroll function - * depends on them being loaded */ - positionImageStatic(img) { - const i = parseInt(img.dataset.index) - if (i == this.flickity.selectedIndex) - img.style.marginLeft = (img.parentElement.clientWidth - img.clientWidth) / 2 + "px" - else if ((i + 1) % this.flickity.cells.length == this.flickity.selectedIndex) - img.style.marginLeft = img.parentElement.clientWidth - img.clientWidth + "px" - } - - imageLoaded(e) { this.positionImageStatic(e.target) } - initImages() { - const imgs = this.slidesContainer.querySelectorAll("#slideshow-carousel img") - for (let i = 0; i < imgs.length; i++) { - const img = imgs[i] - img.dataset.index = i - img.addEventListener("load", e => { this.imageLoaded(e) }) - if (img.complete) - this.positionImageStatic(img) - } - } -} - -export default slidesContainer => { new Slides(slidesContainer); return true } diff --git a/src/client/src/style.css b/src/client/src/style.css deleted file mode 100644 index 552b080..0000000 --- a/src/client/src/style.css +++ /dev/null @@ -1,48 +0,0 @@ -@import "tailwindcss"; -@source "../index.html"; - -:root { - --font-sans: "Overpass", sans-serif; - @apply scheme-dark -} - -.svg-btn { - @apply bg-none border-0 p-0 cursor-pointer -} - -.nav-btn { - @apply flex items-center justify-center no-underline w-full gap-4 p-3 rounded-full - hover:text-blue-300 hover:fill-blue-300 - data-selected:text-blue-300 data-selected:fill-blue-300 - data-selected:bg-gray-900 -} - -.carousel-cell { - @apply h-full w-[70vw] mx-3 md:w-[80vw] md:mx-6 -} -.carousel-img { - @apply align-middle h-full max-w-full object-contain -} - -.album { - @apply flex relative h-40 gap-6 p-3 cursor-pointer - border-y border-slate-800 - data-selected:bg-slate-950 data-selected:hover:bg-slate-900 - hover:bg-gray-900 -} - -.rounded-fieldset { - @apply border border-gray-500 rounded-2xl p-6 flex flex-col gap-4 -} -.fieldset-header { - @apply font-bold text-blue-300 fill-blue-300 text-xl -} -.settings-label { - @apply font-medium text-blue-200 -} -.rounded-input { - @apply rounded-2xl bg-zinc-800 p-4 -} -.rounded-btn { - @apply rounded-full w-fit bg-blue-300 px-4 py-2 text-black fill-black font-medium cursor-pointer flex gap-1 -} diff --git a/src/client/svelte.config.ts b/src/client/svelte.config.ts new file mode 100644 index 0000000..e6eb2e2 --- /dev/null +++ b/src/client/svelte.config.ts @@ -0,0 +1,21 @@ +import adapter from "@sveltejs/adapter-static" + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + output: { + bundleStrategy: "inline", + }, + router: { + type: "hash", + }, + adapter: adapter({ + // default options are shown. On some platforms + // these options are set automatically — see below + pages: "../../dist", + fallback: "index.html", + }) + } +} + +export default config |
