mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
parent
7c2f7d6c51
commit
f55b3add80
242 changed files with 12794 additions and 13426 deletions
|
|
@ -1,121 +1,117 @@
|
|||
<script lang="ts">
|
||||
import { AlbumResponseDto, api } from '@api';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import Plus from 'svelte-material-icons/Plus.svelte';
|
||||
import BaseModal from './base-modal.svelte';
|
||||
import AlbumListItem from '../asset-viewer/album-list-item.svelte';
|
||||
import { AlbumResponseDto, api } from '@api';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import Plus from 'svelte-material-icons/Plus.svelte';
|
||||
import BaseModal from './base-modal.svelte';
|
||||
import AlbumListItem from '../asset-viewer/album-list-item.svelte';
|
||||
|
||||
let albums: AlbumResponseDto[] = [];
|
||||
let recentAlbums: AlbumResponseDto[] = [];
|
||||
let filteredAlbums: AlbumResponseDto[] = [];
|
||||
let loading = true;
|
||||
let search = '';
|
||||
let albums: AlbumResponseDto[] = [];
|
||||
let recentAlbums: AlbumResponseDto[] = [];
|
||||
let filteredAlbums: AlbumResponseDto[] = [];
|
||||
let loading = true;
|
||||
let search = '';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let shared: boolean;
|
||||
export let shared: boolean;
|
||||
|
||||
onMount(async () => {
|
||||
const { data } = await api.albumApi.getAllAlbums({ shared: shared || undefined });
|
||||
albums = data;
|
||||
onMount(async () => {
|
||||
const { data } = await api.albumApi.getAllAlbums({ shared: shared || undefined });
|
||||
albums = data;
|
||||
|
||||
recentAlbums = albums
|
||||
.sort((a, b) => (new Date(a.createdAt) > new Date(b.createdAt) ? -1 : 1))
|
||||
.slice(0, 3);
|
||||
recentAlbums = albums.sort((a, b) => (new Date(a.createdAt) > new Date(b.createdAt) ? -1 : 1)).slice(0, 3);
|
||||
|
||||
loading = false;
|
||||
});
|
||||
loading = false;
|
||||
});
|
||||
|
||||
$: {
|
||||
if (search.length > 0 && albums.length > 0) {
|
||||
filteredAlbums = albums.filter((album) => {
|
||||
return album.albumName.toLowerCase().includes(search.toLowerCase());
|
||||
});
|
||||
} else {
|
||||
filteredAlbums = albums;
|
||||
}
|
||||
}
|
||||
$: {
|
||||
if (search.length > 0 && albums.length > 0) {
|
||||
filteredAlbums = albums.filter((album) => {
|
||||
return album.albumName.toLowerCase().includes(search.toLowerCase());
|
||||
});
|
||||
} else {
|
||||
filteredAlbums = albums;
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (album: AlbumResponseDto) => {
|
||||
dispatch('album', { album });
|
||||
};
|
||||
const handleSelect = (album: AlbumResponseDto) => {
|
||||
dispatch('album', { album });
|
||||
};
|
||||
|
||||
const handleNew = () => {
|
||||
if (shared) {
|
||||
dispatch('newAlbum', { albumName: search.length > 0 ? search : 'Untitled' });
|
||||
} else {
|
||||
dispatch('newSharedAlbum', { albumName: search.length > 0 ? search : 'Untitled' });
|
||||
}
|
||||
};
|
||||
const handleNew = () => {
|
||||
if (shared) {
|
||||
dispatch('newAlbum', { albumName: search.length > 0 ? search : 'Untitled' });
|
||||
} else {
|
||||
dispatch('newSharedAlbum', { albumName: search.length > 0 ? search : 'Untitled' });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<BaseModal on:close={() => dispatch('close')}>
|
||||
<svelte:fragment slot="title">
|
||||
<span class="flex gap-2 place-items-center">
|
||||
<p class="font-medium">
|
||||
Add to {#if shared}Shared {/if} Album
|
||||
</p>
|
||||
</span>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="title">
|
||||
<span class="flex gap-2 place-items-center">
|
||||
<p class="font-medium">
|
||||
Add to {#if shared}Shared {/if} Album
|
||||
</p>
|
||||
</span>
|
||||
</svelte:fragment>
|
||||
|
||||
<div class="max-h-[400px] flex flex-col mb-2">
|
||||
{#if loading}
|
||||
{#each { length: 3 } as _}
|
||||
<div class="animate-pulse flex gap-4 px-6 py-2">
|
||||
<div class="h-12 w-12 bg-slate-200 rounded-xl" />
|
||||
<div class="flex flex-col items-start justify-center gap-2">
|
||||
<span class="animate-pulse w-36 h-4 bg-slate-200" />
|
||||
<div class="flex animate-pulse gap-1">
|
||||
<span class="w-8 h-3 bg-slate-200" />
|
||||
<span class="w-20 h-3 bg-slate-200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
class="px-6 py-2 text-2xl border-b-4 bg-immich-bg border-immich-bg focus:border-immich-primary dark:bg-immich-dark-gray dark:border-immich-dark-gray dark:focus:border-immich-dark-primary"
|
||||
placeholder="Search"
|
||||
autofocus
|
||||
bind:value={search}
|
||||
/>
|
||||
<div class="overflow-y-auto immich-scrollbar">
|
||||
<button
|
||||
on:click={handleNew}
|
||||
class="w-full flex gap-4 px-6 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors items-center"
|
||||
>
|
||||
<div class="h-12 w-12 flex justify-center items-center">
|
||||
<Plus size="30" />
|
||||
</div>
|
||||
<p class="">
|
||||
New {#if shared}Shared {/if}Album {#if search.length > 0}<b>{search}</b>{/if}
|
||||
</p>
|
||||
</button>
|
||||
{#if filteredAlbums.length > 0}
|
||||
{#if !shared && search.length === 0}
|
||||
<p class="text-xs px-5 py-3">RECENT</p>
|
||||
{#each recentAlbums as album (album.id)}
|
||||
<AlbumListItem variant="simple" {album} on:album={() => handleSelect(album)} />
|
||||
{/each}
|
||||
{/if}
|
||||
<div class="max-h-[400px] flex flex-col mb-2">
|
||||
{#if loading}
|
||||
{#each { length: 3 } as _}
|
||||
<div class="animate-pulse flex gap-4 px-6 py-2">
|
||||
<div class="h-12 w-12 bg-slate-200 rounded-xl" />
|
||||
<div class="flex flex-col items-start justify-center gap-2">
|
||||
<span class="animate-pulse w-36 h-4 bg-slate-200" />
|
||||
<div class="flex animate-pulse gap-1">
|
||||
<span class="w-8 h-3 bg-slate-200" />
|
||||
<span class="w-20 h-3 bg-slate-200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
class="px-6 py-2 text-2xl border-b-4 bg-immich-bg border-immich-bg focus:border-immich-primary dark:bg-immich-dark-gray dark:border-immich-dark-gray dark:focus:border-immich-dark-primary"
|
||||
placeholder="Search"
|
||||
autofocus
|
||||
bind:value={search}
|
||||
/>
|
||||
<div class="overflow-y-auto immich-scrollbar">
|
||||
<button
|
||||
on:click={handleNew}
|
||||
class="w-full flex gap-4 px-6 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors items-center"
|
||||
>
|
||||
<div class="h-12 w-12 flex justify-center items-center">
|
||||
<Plus size="30" />
|
||||
</div>
|
||||
<p class="">
|
||||
New {#if shared}Shared {/if}Album {#if search.length > 0}<b>{search}</b>{/if}
|
||||
</p>
|
||||
</button>
|
||||
{#if filteredAlbums.length > 0}
|
||||
{#if !shared && search.length === 0}
|
||||
<p class="text-xs px-5 py-3">RECENT</p>
|
||||
{#each recentAlbums as album (album.id)}
|
||||
<AlbumListItem variant="simple" {album} on:album={() => handleSelect(album)} />
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if !shared}
|
||||
<p class="text-xs px-5 py-3">
|
||||
{#if search.length === 0}ALL {/if}ALBUMS
|
||||
</p>
|
||||
{/if}
|
||||
{#each filteredAlbums as album (album.id)}
|
||||
<AlbumListItem {album} searchQuery={search} on:album={() => handleSelect(album)} />
|
||||
{/each}
|
||||
{:else if albums.length > 0}
|
||||
<p class="text-sm px-5 py-1">
|
||||
It looks like you do not have any albums with this name yet.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-sm px-5 py-1">It looks like you do not have any albums yet.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !shared}
|
||||
<p class="text-xs px-5 py-3">
|
||||
{#if search.length === 0}ALL {/if}ALBUMS
|
||||
</p>
|
||||
{/if}
|
||||
{#each filteredAlbums as album (album.id)}
|
||||
<AlbumListItem {album} searchQuery={search} on:album={() => handleSelect(album)} />
|
||||
{/each}
|
||||
{:else if albums.length > 0}
|
||||
<p class="text-sm px-5 py-1">It looks like you do not have any albums with this name yet.</p>
|
||||
{:else}
|
||||
<p class="text-sm px-5 py-1">It looks like you do not have any albums yet.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</BaseModal>
|
||||
|
|
|
|||
|
|
@ -1,184 +1,184 @@
|
|||
<script lang="ts">
|
||||
import appleSplash20482732 from '$lib/assets/apple/apple-splash-2048-2732.png';
|
||||
import appleSplash27322048 from '$lib/assets/apple/apple-splash-2732-2048.png';
|
||||
import appleSplash16682388 from '$lib/assets/apple/apple-splash-1668-2388.png';
|
||||
import appleSplash23881668 from '$lib/assets/apple/apple-splash-2388-1668.png';
|
||||
import appleSplash15362048 from '$lib/assets/apple/apple-splash-1536-2048.png';
|
||||
import appleSplash20481536 from '$lib/assets/apple/apple-splash-2048-1536.png';
|
||||
import appleSplash16682224 from '$lib/assets/apple/apple-splash-1668-2224.png';
|
||||
import appleSplash22241668 from '$lib/assets/apple/apple-splash-2224-1668.png';
|
||||
import appleSplash16202160 from '$lib/assets/apple/apple-splash-1620-2160.png';
|
||||
import appleSplash21601620 from '$lib/assets/apple/apple-splash-2160-1620.png';
|
||||
import appleSplash12902796 from '$lib/assets/apple/apple-splash-1290-2796.png';
|
||||
import appleSplash27961290 from '$lib/assets/apple/apple-splash-2796-1290.png';
|
||||
import appleSplash11792556 from '$lib/assets/apple/apple-splash-1179-2556.png';
|
||||
import appleSplash25561179 from '$lib/assets/apple/apple-splash-2556-1179.png';
|
||||
import appleSplash12842778 from '$lib/assets/apple/apple-splash-1284-2778.png';
|
||||
import appleSplash27781284 from '$lib/assets/apple/apple-splash-2778-1284.png';
|
||||
import appleSplash11702532 from '$lib/assets/apple/apple-splash-1170-2532.png';
|
||||
import appleSplash25321170 from '$lib/assets/apple/apple-splash-2532-1170.png';
|
||||
import appleSplash11252436 from '$lib/assets/apple/apple-splash-1125-2436.png';
|
||||
import appleSplash24361125 from '$lib/assets/apple/apple-splash-2436-1125.png';
|
||||
import appleSplash12422688 from '$lib/assets/apple/apple-splash-1242-2688.png';
|
||||
import appleSplash26881242 from '$lib/assets/apple/apple-splash-2688-1242.png';
|
||||
import appleSplash8281792 from '$lib/assets/apple/apple-splash-828-1792.png';
|
||||
import appleSplash1792828 from '$lib/assets/apple/apple-splash-1792-828.png';
|
||||
import appleSplash12422208 from '$lib/assets/apple/apple-splash-1242-2208.png';
|
||||
import appleSplash22081242 from '$lib/assets/apple/apple-splash-2208-1242.png';
|
||||
import appleSplash7501334 from '$lib/assets/apple/apple-splash-750-1334.png';
|
||||
import appleSplash1334750 from '$lib/assets/apple/apple-splash-1334-750.png';
|
||||
import appleSplash6401136 from '$lib/assets/apple/apple-splash-640-1136.png';
|
||||
import appleSplash1136640 from '$lib/assets/apple/apple-splash-1136-640.png';
|
||||
import appleSplash20482732 from '$lib/assets/apple/apple-splash-2048-2732.png';
|
||||
import appleSplash27322048 from '$lib/assets/apple/apple-splash-2732-2048.png';
|
||||
import appleSplash16682388 from '$lib/assets/apple/apple-splash-1668-2388.png';
|
||||
import appleSplash23881668 from '$lib/assets/apple/apple-splash-2388-1668.png';
|
||||
import appleSplash15362048 from '$lib/assets/apple/apple-splash-1536-2048.png';
|
||||
import appleSplash20481536 from '$lib/assets/apple/apple-splash-2048-1536.png';
|
||||
import appleSplash16682224 from '$lib/assets/apple/apple-splash-1668-2224.png';
|
||||
import appleSplash22241668 from '$lib/assets/apple/apple-splash-2224-1668.png';
|
||||
import appleSplash16202160 from '$lib/assets/apple/apple-splash-1620-2160.png';
|
||||
import appleSplash21601620 from '$lib/assets/apple/apple-splash-2160-1620.png';
|
||||
import appleSplash12902796 from '$lib/assets/apple/apple-splash-1290-2796.png';
|
||||
import appleSplash27961290 from '$lib/assets/apple/apple-splash-2796-1290.png';
|
||||
import appleSplash11792556 from '$lib/assets/apple/apple-splash-1179-2556.png';
|
||||
import appleSplash25561179 from '$lib/assets/apple/apple-splash-2556-1179.png';
|
||||
import appleSplash12842778 from '$lib/assets/apple/apple-splash-1284-2778.png';
|
||||
import appleSplash27781284 from '$lib/assets/apple/apple-splash-2778-1284.png';
|
||||
import appleSplash11702532 from '$lib/assets/apple/apple-splash-1170-2532.png';
|
||||
import appleSplash25321170 from '$lib/assets/apple/apple-splash-2532-1170.png';
|
||||
import appleSplash11252436 from '$lib/assets/apple/apple-splash-1125-2436.png';
|
||||
import appleSplash24361125 from '$lib/assets/apple/apple-splash-2436-1125.png';
|
||||
import appleSplash12422688 from '$lib/assets/apple/apple-splash-1242-2688.png';
|
||||
import appleSplash26881242 from '$lib/assets/apple/apple-splash-2688-1242.png';
|
||||
import appleSplash8281792 from '$lib/assets/apple/apple-splash-828-1792.png';
|
||||
import appleSplash1792828 from '$lib/assets/apple/apple-splash-1792-828.png';
|
||||
import appleSplash12422208 from '$lib/assets/apple/apple-splash-1242-2208.png';
|
||||
import appleSplash22081242 from '$lib/assets/apple/apple-splash-2208-1242.png';
|
||||
import appleSplash7501334 from '$lib/assets/apple/apple-splash-750-1334.png';
|
||||
import appleSplash1334750 from '$lib/assets/apple/apple-splash-1334-750.png';
|
||||
import appleSplash6401136 from '$lib/assets/apple/apple-splash-640-1136.png';
|
||||
import appleSplash1136640 from '$lib/assets/apple/apple-splash-1136-640.png';
|
||||
</script>
|
||||
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash20482732}
|
||||
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash20482732}
|
||||
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash27322048}
|
||||
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash27322048}
|
||||
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash16682388}
|
||||
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash16682388}
|
||||
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash23881668}
|
||||
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash23881668}
|
||||
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash15362048}
|
||||
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash15362048}
|
||||
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash20481536}
|
||||
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash20481536}
|
||||
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash16682224}
|
||||
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash16682224}
|
||||
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash22241668}
|
||||
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash22241668}
|
||||
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash16202160}
|
||||
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash16202160}
|
||||
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash21601620}
|
||||
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash21601620}
|
||||
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash12902796}
|
||||
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash12902796}
|
||||
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash27961290}
|
||||
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash27961290}
|
||||
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
/>
|
||||
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash11792556}
|
||||
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash11792556}
|
||||
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash25561179}
|
||||
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash25561179}
|
||||
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash12842778}
|
||||
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash12842778}
|
||||
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash27781284}
|
||||
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash27781284}
|
||||
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash11702532}
|
||||
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash11702532}
|
||||
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash25321170}
|
||||
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash25321170}
|
||||
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash11252436}
|
||||
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash11252436}
|
||||
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash24361125}
|
||||
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash24361125}
|
||||
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash12422688}
|
||||
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash12422688}
|
||||
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash26881242}
|
||||
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash26881242}
|
||||
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash8281792}
|
||||
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash8281792}
|
||||
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash1792828}
|
||||
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash1792828}
|
||||
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash12422208}
|
||||
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash12422208}
|
||||
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash22081242}
|
||||
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash22081242}
|
||||
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash7501334}
|
||||
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash7501334}
|
||||
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash1334750}
|
||||
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash1334750}
|
||||
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash6401136}
|
||||
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash6401136}
|
||||
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash1136640}
|
||||
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
rel="apple-touch-startup-image"
|
||||
href={appleSplash1136640}
|
||||
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,55 +1,55 @@
|
|||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import Close from 'svelte-material-icons/Close.svelte';
|
||||
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
import { clickOutside } from '$lib/utils/click-outside';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import Close from 'svelte-material-icons/Close.svelte';
|
||||
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
import { clickOutside } from '$lib/utils/click-outside';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
export let zIndex = 9999;
|
||||
const dispatch = createEventDispatcher();
|
||||
export let zIndex = 9999;
|
||||
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
const scrollTop = document.documentElement.scrollTop;
|
||||
const scrollLeft = document.documentElement.scrollLeft;
|
||||
window.onscroll = function () {
|
||||
window.scrollTo(scrollLeft, scrollTop);
|
||||
};
|
||||
}
|
||||
});
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
const scrollTop = document.documentElement.scrollTop;
|
||||
const scrollLeft = document.documentElement.scrollLeft;
|
||||
window.onscroll = function () {
|
||||
window.scrollTo(scrollLeft, scrollTop);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (browser) {
|
||||
window.onscroll = null;
|
||||
}
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (browser) {
|
||||
window.onscroll = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
id="immich-modal"
|
||||
style:z-index={zIndex}
|
||||
transition:fade={{ duration: 100, easing: quintOut }}
|
||||
class="fixed top-0 left-0 w-full h-full bg-black/50 flex place-items-center place-content-center overflow-hidden"
|
||||
id="immich-modal"
|
||||
style:z-index={zIndex}
|
||||
transition:fade={{ duration: 100, easing: quintOut }}
|
||||
class="fixed top-0 left-0 w-full h-full bg-black/50 flex place-items-center place-content-center overflow-hidden"
|
||||
>
|
||||
<div
|
||||
use:clickOutside
|
||||
on:outclick={() => dispatch('close')}
|
||||
class="bg-immich-bg dark:bg-immich-dark-gray dark:text-immich-dark-fg w-[450px] min-h-[200px] max-h-[600px] rounded-lg shadow-md"
|
||||
>
|
||||
<div class="flex justify-between place-items-center px-5 py-3">
|
||||
<div>
|
||||
<slot name="title">
|
||||
<p>Modal Title</p>
|
||||
</slot>
|
||||
</div>
|
||||
<div
|
||||
use:clickOutside
|
||||
on:outclick={() => dispatch('close')}
|
||||
class="bg-immich-bg dark:bg-immich-dark-gray dark:text-immich-dark-fg w-[450px] min-h-[200px] max-h-[600px] rounded-lg shadow-md"
|
||||
>
|
||||
<div class="flex justify-between place-items-center px-5 py-3">
|
||||
<div>
|
||||
<slot name="title">
|
||||
<p>Modal Title</p>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<CircleIconButton on:click={() => dispatch('close')} logo={Close} size={'20'} />
|
||||
</div>
|
||||
<CircleIconButton on:click={() => dispatch('close')} logo={Close} size={'20'} />
|
||||
</div>
|
||||
|
||||
<div class="">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
<div class="">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,62 +1,57 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import FullScreenModal from './full-screen-modal.svelte';
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
import type { Color } from '$lib/components/elements/buttons/button.svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import FullScreenModal from './full-screen-modal.svelte';
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
import type { Color } from '$lib/components/elements/buttons/button.svelte';
|
||||
|
||||
export let title = 'Confirm';
|
||||
export let prompt = 'Are you sure you want to do this?';
|
||||
export let confirmText = 'Confirm';
|
||||
export let confirmColor: Color = 'red';
|
||||
export let cancelText = 'Cancel';
|
||||
export let cancelColor: Color = 'primary';
|
||||
export let hideCancelButton = false;
|
||||
export let title = 'Confirm';
|
||||
export let prompt = 'Are you sure you want to do this?';
|
||||
export let confirmText = 'Confirm';
|
||||
export let confirmColor: Color = 'red';
|
||||
export let cancelText = 'Cancel';
|
||||
export let cancelColor: Color = 'primary';
|
||||
export let hideCancelButton = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let isConfirmButtonDisabled = false;
|
||||
let isConfirmButtonDisabled = false;
|
||||
|
||||
const handleCancel = () => dispatch('cancel');
|
||||
const handleCancel = () => dispatch('cancel');
|
||||
|
||||
const handleConfirm = () => {
|
||||
isConfirmButtonDisabled = true;
|
||||
dispatch('confirm');
|
||||
};
|
||||
const handleConfirm = () => {
|
||||
isConfirmButtonDisabled = true;
|
||||
dispatch('confirm');
|
||||
};
|
||||
</script>
|
||||
|
||||
<FullScreenModal on:clickOutside={handleCancel}>
|
||||
<div
|
||||
class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] max-w-[95vw] rounded-3xl py-8 dark:text-immich-dark-fg"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col place-items-center place-content-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
|
||||
>
|
||||
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium pb-2">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
<div>
|
||||
<div class="px-4 py-5 text-md text-center">
|
||||
<slot name="prompt">
|
||||
<p>{prompt}</p>
|
||||
</slot>
|
||||
</div>
|
||||
<div
|
||||
class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] max-w-[95vw] rounded-3xl py-8 dark:text-immich-dark-fg"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col place-items-center place-content-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
|
||||
>
|
||||
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium pb-2">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
<div>
|
||||
<div class="px-4 py-5 text-md text-center">
|
||||
<slot name="prompt">
|
||||
<p>{prompt}</p>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full px-4 gap-4 mt-4">
|
||||
{#if !hideCancelButton}
|
||||
<Button color={cancelColor} fullwidth on:click={handleCancel}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
color={confirmColor}
|
||||
fullwidth
|
||||
on:click={handleConfirm}
|
||||
disabled={isConfirmButtonDisabled}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full px-4 gap-4 mt-4">
|
||||
{#if !hideCancelButton}
|
||||
<Button color={cancelColor} fullwidth on:click={handleCancel}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button color={confirmColor} fullwidth on:click={handleConfirm} disabled={isConfirmButtonDisabled}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FullScreenModal>
|
||||
|
|
|
|||
|
|
@ -1,33 +1,33 @@
|
|||
<script lang="ts">
|
||||
import { clickOutside } from '$lib/utils/click-outside';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { clickOutside } from '$lib/utils/click-outside';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
export let direction: 'left' | 'right' = 'right';
|
||||
export let x = 0;
|
||||
export let y = 0;
|
||||
export let direction: 'left' | 'right' = 'right';
|
||||
export let x = 0;
|
||||
export let y = 0;
|
||||
|
||||
let menuElement: HTMLDivElement;
|
||||
let left: number;
|
||||
let top: number;
|
||||
let menuElement: HTMLDivElement;
|
||||
let left: number;
|
||||
let top: number;
|
||||
|
||||
$: if (menuElement) {
|
||||
const rect = menuElement.getBoundingClientRect();
|
||||
const directionWidth = direction === 'left' ? rect.width : 0;
|
||||
$: if (menuElement) {
|
||||
const rect = menuElement.getBoundingClientRect();
|
||||
const directionWidth = direction === 'left' ? rect.width : 0;
|
||||
|
||||
left = Math.min(window.innerWidth - rect.width, x - directionWidth);
|
||||
top = Math.min(window.innerHeight - rect.height, y);
|
||||
}
|
||||
left = Math.min(window.innerWidth - rect.width, x - directionWidth);
|
||||
top = Math.min(window.innerHeight - rect.height, y);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
transition:slide={{ duration: 200, easing: quintOut }}
|
||||
bind:this={menuElement}
|
||||
class="absolute w-[200px] z-[99999] rounded-lg overflow-hidden shadow-lg"
|
||||
style="left: {left}px; top: {top}px;"
|
||||
role="menu"
|
||||
use:clickOutside
|
||||
on:outclick
|
||||
transition:slide={{ duration: 200, easing: quintOut }}
|
||||
bind:this={menuElement}
|
||||
class="absolute w-[200px] z-[99999] rounded-lg overflow-hidden shadow-lg"
|
||||
style="left: {left}px; top: {top}px;"
|
||||
role="menu"
|
||||
use:clickOutside
|
||||
on:outclick
|
||||
>
|
||||
<slot />
|
||||
<slot />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
<script>
|
||||
export let text = '';
|
||||
export let text = '';
|
||||
</script>
|
||||
|
||||
<button
|
||||
on:click
|
||||
class="bg-slate-100 hover:bg-gray-200 text-immich-fg dark:text-immich-dark-bg p-4 w-full text-left text-sm font-medium focus:outline-none focus:ring-inset focus:ring-2"
|
||||
role="menuitem"
|
||||
on:click
|
||||
class="bg-slate-100 hover:bg-gray-200 text-immich-fg dark:text-immich-dark-bg p-4 w-full text-left text-sm font-medium focus:outline-none focus:ring-inset focus:ring-2"
|
||||
role="menuitem"
|
||||
>
|
||||
{#if text}
|
||||
{text}
|
||||
{:else}
|
||||
<slot />
|
||||
{/if}
|
||||
{#if text}
|
||||
{text}
|
||||
{:else}
|
||||
<slot />
|
||||
{/if}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,72 +1,72 @@
|
|||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
|
||||
import Close from 'svelte-material-icons/Close.svelte';
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
|
||||
import Close from 'svelte-material-icons/Close.svelte';
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
export let showBackButton = true;
|
||||
export let backIcon = Close;
|
||||
export let tailwindClasses = '';
|
||||
export let forceDark = false;
|
||||
export let showBackButton = true;
|
||||
export let backIcon = Close;
|
||||
export let tailwindClasses = '';
|
||||
export let forceDark = false;
|
||||
|
||||
let appBarBorder = 'bg-immich-bg border border-transparent';
|
||||
let appBarBorder = 'bg-immich-bg border border-transparent';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const onScroll = () => {
|
||||
if (window.pageYOffset > 80) {
|
||||
appBarBorder = 'border border-gray-200 bg-gray-50 dark:border-gray-600';
|
||||
const onScroll = () => {
|
||||
if (window.pageYOffset > 80) {
|
||||
appBarBorder = 'border border-gray-200 bg-gray-50 dark:border-gray-600';
|
||||
|
||||
if (forceDark) {
|
||||
appBarBorder = 'border border-gray-600';
|
||||
}
|
||||
} else {
|
||||
appBarBorder = 'bg-immich-bg border border-transparent';
|
||||
}
|
||||
};
|
||||
if (forceDark) {
|
||||
appBarBorder = 'border border-gray-600';
|
||||
}
|
||||
} else {
|
||||
appBarBorder = 'bg-immich-bg border border-transparent';
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
document.addEventListener('scroll', onScroll);
|
||||
}
|
||||
});
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
document.addEventListener('scroll', onScroll);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (browser) {
|
||||
document.removeEventListener('scroll', onScroll);
|
||||
}
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (browser) {
|
||||
document.removeEventListener('scroll', onScroll);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div in:fly={{ y: 10, duration: 200 }} class="fixed top-0 w-full bg-transparent z-[100]">
|
||||
<div
|
||||
id="asset-selection-app-bar"
|
||||
class={`grid grid-cols-3 justify-between ${appBarBorder} rounded-lg p-2 mx-2 mt-2 transition-all place-items-center ${tailwindClasses} dark:bg-immich-dark-gray ${
|
||||
forceDark && 'bg-immich-dark-gray text-white'
|
||||
}`}
|
||||
>
|
||||
<div class="flex place-items-center gap-6 dark:text-immich-dark-fg justify-self-start">
|
||||
{#if showBackButton}
|
||||
<CircleIconButton
|
||||
on:click={() => dispatch('close-button-click')}
|
||||
logo={backIcon}
|
||||
backgroundColor={'transparent'}
|
||||
hoverColor={'#e2e7e9'}
|
||||
size={'24'}
|
||||
forceDark
|
||||
/>
|
||||
{/if}
|
||||
<slot name="leading" />
|
||||
</div>
|
||||
<div
|
||||
id="asset-selection-app-bar"
|
||||
class={`grid grid-cols-3 justify-between ${appBarBorder} rounded-lg p-2 mx-2 mt-2 transition-all place-items-center ${tailwindClasses} dark:bg-immich-dark-gray ${
|
||||
forceDark && 'bg-immich-dark-gray text-white'
|
||||
}`}
|
||||
>
|
||||
<div class="flex place-items-center gap-6 dark:text-immich-dark-fg justify-self-start">
|
||||
{#if showBackButton}
|
||||
<CircleIconButton
|
||||
on:click={() => dispatch('close-button-click')}
|
||||
logo={backIcon}
|
||||
backgroundColor={'transparent'}
|
||||
hoverColor={'#e2e7e9'}
|
||||
size={'24'}
|
||||
forceDark
|
||||
/>
|
||||
{/if}
|
||||
<slot name="leading" />
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div class="flex place-items-center gap-1 mr-4 justify-self-end">
|
||||
<slot name="trailing" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex place-items-center gap-1 mr-4 justify-self-end">
|
||||
<slot name="trailing" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,259 +1,241 @@
|
|||
<script lang="ts">
|
||||
import SettingInputField, {
|
||||
SettingInputFieldType
|
||||
} from '$lib/components/admin-page/settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '$lib/components/admin-page/settings/setting-switch.svelte';
|
||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
AlbumResponseDto,
|
||||
api,
|
||||
AssetResponseDto,
|
||||
SharedLinkResponseDto,
|
||||
SharedLinkType
|
||||
} from '@api';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import Link from 'svelte-material-icons/Link.svelte';
|
||||
import BaseModal from '../base-modal.svelte';
|
||||
import type { ImmichDropDownOption } from '../dropdown-button.svelte';
|
||||
import DropdownButton from '../dropdown-button.svelte';
|
||||
import { notificationController, NotificationType } from '../notification/notification';
|
||||
import SettingInputField, {
|
||||
SettingInputFieldType,
|
||||
} from '$lib/components/admin-page/settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '$lib/components/admin-page/settings/setting-switch.svelte';
|
||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { AlbumResponseDto, api, AssetResponseDto, SharedLinkResponseDto, SharedLinkType } from '@api';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import Link from 'svelte-material-icons/Link.svelte';
|
||||
import BaseModal from '../base-modal.svelte';
|
||||
import type { ImmichDropDownOption } from '../dropdown-button.svelte';
|
||||
import DropdownButton from '../dropdown-button.svelte';
|
||||
import { notificationController, NotificationType } from '../notification/notification';
|
||||
|
||||
export let shareType: SharedLinkType;
|
||||
export let sharedAssets: AssetResponseDto[] = [];
|
||||
export let album: AlbumResponseDto | undefined = undefined;
|
||||
export let editingLink: SharedLinkResponseDto | undefined = undefined;
|
||||
export let shareType: SharedLinkType;
|
||||
export let sharedAssets: AssetResponseDto[] = [];
|
||||
export let album: AlbumResponseDto | undefined = undefined;
|
||||
export let editingLink: SharedLinkResponseDto | undefined = undefined;
|
||||
|
||||
let sharedLink: string | null = null;
|
||||
let description = '';
|
||||
let allowDownload = true;
|
||||
let allowUpload = false;
|
||||
let showExif = true;
|
||||
let expirationTime = '';
|
||||
let shouldChangeExpirationTime = false;
|
||||
let canCopyImagesToClipboard = true;
|
||||
const dispatch = createEventDispatcher();
|
||||
let sharedLink: string | null = null;
|
||||
let description = '';
|
||||
let allowDownload = true;
|
||||
let allowUpload = false;
|
||||
let showExif = true;
|
||||
let expirationTime = '';
|
||||
let shouldChangeExpirationTime = false;
|
||||
let canCopyImagesToClipboard = true;
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const expiredDateOption: ImmichDropDownOption = {
|
||||
default: 'Never',
|
||||
options: ['Never', '30 minutes', '1 hour', '6 hours', '1 day', '7 days', '30 days']
|
||||
};
|
||||
const expiredDateOption: ImmichDropDownOption = {
|
||||
default: 'Never',
|
||||
options: ['Never', '30 minutes', '1 hour', '6 hours', '1 day', '7 days', '30 days'],
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
if (editingLink) {
|
||||
if (editingLink.description) {
|
||||
description = editingLink.description;
|
||||
}
|
||||
allowUpload = editingLink.allowUpload;
|
||||
allowDownload = editingLink.allowDownload;
|
||||
showExif = editingLink.showExif;
|
||||
}
|
||||
onMount(async () => {
|
||||
if (editingLink) {
|
||||
if (editingLink.description) {
|
||||
description = editingLink.description;
|
||||
}
|
||||
allowUpload = editingLink.allowUpload;
|
||||
allowDownload = editingLink.allowDownload;
|
||||
showExif = editingLink.showExif;
|
||||
}
|
||||
|
||||
const module = await import('copy-image-clipboard');
|
||||
canCopyImagesToClipboard = module.canCopyImagesToClipboard();
|
||||
});
|
||||
const module = await import('copy-image-clipboard');
|
||||
canCopyImagesToClipboard = module.canCopyImagesToClipboard();
|
||||
});
|
||||
|
||||
const handleCreateSharedLink = async () => {
|
||||
const expirationTime = getExpirationTimeInMillisecond();
|
||||
const currentTime = new Date().getTime();
|
||||
const expirationDate = expirationTime
|
||||
? new Date(currentTime + expirationTime).toISOString()
|
||||
: undefined;
|
||||
const handleCreateSharedLink = async () => {
|
||||
const expirationTime = getExpirationTimeInMillisecond();
|
||||
const currentTime = new Date().getTime();
|
||||
const expirationDate = expirationTime ? new Date(currentTime + expirationTime).toISOString() : undefined;
|
||||
|
||||
try {
|
||||
const { data } = await api.sharedLinkApi.createSharedLink({
|
||||
sharedLinkCreateDto: {
|
||||
type: shareType,
|
||||
albumId: album ? album.id : undefined,
|
||||
assetIds: sharedAssets.map((a) => a.id),
|
||||
expiresAt: expirationDate,
|
||||
allowUpload,
|
||||
description,
|
||||
allowDownload,
|
||||
showExif
|
||||
}
|
||||
});
|
||||
sharedLink = `${window.location.origin}/share/${data.key}`;
|
||||
} catch (e) {
|
||||
handleError(e, 'Failed to create shared link');
|
||||
}
|
||||
};
|
||||
try {
|
||||
const { data } = await api.sharedLinkApi.createSharedLink({
|
||||
sharedLinkCreateDto: {
|
||||
type: shareType,
|
||||
albumId: album ? album.id : undefined,
|
||||
assetIds: sharedAssets.map((a) => a.id),
|
||||
expiresAt: expirationDate,
|
||||
allowUpload,
|
||||
description,
|
||||
allowDownload,
|
||||
showExif,
|
||||
},
|
||||
});
|
||||
sharedLink = `${window.location.origin}/share/${data.key}`;
|
||||
} catch (e) {
|
||||
handleError(e, 'Failed to create shared link');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!sharedLink) {
|
||||
return;
|
||||
}
|
||||
const handleCopy = async () => {
|
||||
if (!sharedLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(sharedLink);
|
||||
notificationController.show({ message: 'Copied to clipboard!', type: NotificationType.Info });
|
||||
} catch (e) {
|
||||
handleError(
|
||||
e,
|
||||
'Cannot copy to clipboard, make sure you are accessing the page through https'
|
||||
);
|
||||
}
|
||||
};
|
||||
try {
|
||||
await navigator.clipboard.writeText(sharedLink);
|
||||
notificationController.show({ message: 'Copied to clipboard!', type: NotificationType.Info });
|
||||
} catch (e) {
|
||||
handleError(e, 'Cannot copy to clipboard, make sure you are accessing the page through https');
|
||||
}
|
||||
};
|
||||
|
||||
const getExpirationTimeInMillisecond = () => {
|
||||
switch (expirationTime) {
|
||||
case '30 minutes':
|
||||
return 30 * 60 * 1000;
|
||||
case '1 hour':
|
||||
return 60 * 60 * 1000;
|
||||
case '6 hours':
|
||||
return 6 * 60 * 60 * 1000;
|
||||
case '1 day':
|
||||
return 24 * 60 * 60 * 1000;
|
||||
case '7 days':
|
||||
return 7 * 24 * 60 * 60 * 1000;
|
||||
case '30 days':
|
||||
return 30 * 24 * 60 * 60 * 1000;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
const getExpirationTimeInMillisecond = () => {
|
||||
switch (expirationTime) {
|
||||
case '30 minutes':
|
||||
return 30 * 60 * 1000;
|
||||
case '1 hour':
|
||||
return 60 * 60 * 1000;
|
||||
case '6 hours':
|
||||
return 6 * 60 * 60 * 1000;
|
||||
case '1 day':
|
||||
return 24 * 60 * 60 * 1000;
|
||||
case '7 days':
|
||||
return 7 * 24 * 60 * 60 * 1000;
|
||||
case '30 days':
|
||||
return 30 * 24 * 60 * 60 * 1000;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditLink = async () => {
|
||||
if (!editingLink) {
|
||||
return;
|
||||
}
|
||||
const handleEditLink = async () => {
|
||||
if (!editingLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const expirationTime = getExpirationTimeInMillisecond();
|
||||
const currentTime = new Date().getTime();
|
||||
const expirationDate: string | null = expirationTime
|
||||
? new Date(currentTime + expirationTime).toISOString()
|
||||
: null;
|
||||
try {
|
||||
const expirationTime = getExpirationTimeInMillisecond();
|
||||
const currentTime = new Date().getTime();
|
||||
const expirationDate: string | null = expirationTime
|
||||
? new Date(currentTime + expirationTime).toISOString()
|
||||
: null;
|
||||
|
||||
await api.sharedLinkApi.updateSharedLink({
|
||||
id: editingLink.id,
|
||||
sharedLinkEditDto: {
|
||||
description,
|
||||
expiresAt: shouldChangeExpirationTime ? expirationDate : undefined,
|
||||
allowUpload: allowUpload,
|
||||
allowDownload: allowDownload,
|
||||
showExif: showExif
|
||||
}
|
||||
});
|
||||
await api.sharedLinkApi.updateSharedLink({
|
||||
id: editingLink.id,
|
||||
sharedLinkEditDto: {
|
||||
description,
|
||||
expiresAt: shouldChangeExpirationTime ? expirationDate : undefined,
|
||||
allowUpload: allowUpload,
|
||||
allowDownload: allowDownload,
|
||||
showExif: showExif,
|
||||
},
|
||||
});
|
||||
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: 'Edited'
|
||||
});
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: 'Edited',
|
||||
});
|
||||
|
||||
dispatch('close');
|
||||
} catch (e) {
|
||||
handleError(e, 'Failed to edit shared link');
|
||||
}
|
||||
};
|
||||
dispatch('close');
|
||||
} catch (e) {
|
||||
handleError(e, 'Failed to edit shared link');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<BaseModal on:close={() => dispatch('close')}>
|
||||
<svelte:fragment slot="title">
|
||||
<span class="flex gap-2 place-items-center">
|
||||
<Link size={24} />
|
||||
{#if editingLink}
|
||||
<p class="font-medium text-immich-fg dark:text-immich-dark-fg">Edit link</p>
|
||||
{:else}
|
||||
<p class="font-medium text-immich-fg dark:text-immich-dark-fg">Create link to share</p>
|
||||
{/if}
|
||||
</span>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="title">
|
||||
<span class="flex gap-2 place-items-center">
|
||||
<Link size={24} />
|
||||
{#if editingLink}
|
||||
<p class="font-medium text-immich-fg dark:text-immich-dark-fg">Edit link</p>
|
||||
{:else}
|
||||
<p class="font-medium text-immich-fg dark:text-immich-dark-fg">Create link to share</p>
|
||||
{/if}
|
||||
</span>
|
||||
</svelte:fragment>
|
||||
|
||||
<section class="mx-6 mb-6">
|
||||
{#if shareType == SharedLinkType.Album}
|
||||
{#if !editingLink}
|
||||
<div>Let anyone with the link see photos and people in this album.</div>
|
||||
{:else}
|
||||
<div class="text-sm">
|
||||
Public album | <span class="text-immich-primary dark:text-immich-dark-primary"
|
||||
>{editingLink.album?.albumName}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<section class="mx-6 mb-6">
|
||||
{#if shareType == SharedLinkType.Album}
|
||||
{#if !editingLink}
|
||||
<div>Let anyone with the link see photos and people in this album.</div>
|
||||
{:else}
|
||||
<div class="text-sm">
|
||||
Public album | <span class="text-immich-primary dark:text-immich-dark-primary"
|
||||
>{editingLink.album?.albumName}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if shareType == SharedLinkType.Individual}
|
||||
{#if !editingLink}
|
||||
<div>Let anyone with the link see the selected photo(s)</div>
|
||||
{:else}
|
||||
<div class="text-sm">
|
||||
Individual shared | <span class="text-immich-primary dark:text-immich-dark-primary"
|
||||
>{editingLink.description}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if shareType == SharedLinkType.Individual}
|
||||
{#if !editingLink}
|
||||
<div>Let anyone with the link see the selected photo(s)</div>
|
||||
{:else}
|
||||
<div class="text-sm">
|
||||
Individual shared | <span class="text-immich-primary dark:text-immich-dark-primary"
|
||||
>{editingLink.description}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 mb-2">
|
||||
<p class="text-xs">LINK OPTIONS</p>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-100 dark:bg-black/40 rounded-lg">
|
||||
<div class="flex flex-col">
|
||||
<div class="mb-2">
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label="Description"
|
||||
bind:value={description}
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4 mb-2">
|
||||
<p class="text-xs">LINK OPTIONS</p>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-100 dark:bg-black/40 rounded-lg">
|
||||
<div class="flex flex-col">
|
||||
<div class="mb-2">
|
||||
<SettingInputField inputType={SettingInputFieldType.TEXT} label="Description" bind:value={description} />
|
||||
</div>
|
||||
|
||||
<div class="my-3">
|
||||
<SettingSwitch bind:checked={showExif} title={'Show metadata'} />
|
||||
</div>
|
||||
<div class="my-3">
|
||||
<SettingSwitch bind:checked={showExif} title={'Show metadata'} />
|
||||
</div>
|
||||
|
||||
<div class="my-3">
|
||||
<SettingSwitch bind:checked={allowDownload} title={'Allow public user to download'} />
|
||||
</div>
|
||||
<div class="my-3">
|
||||
<SettingSwitch bind:checked={allowDownload} title={'Allow public user to download'} />
|
||||
</div>
|
||||
|
||||
<div class="my-3">
|
||||
<SettingSwitch bind:checked={allowUpload} title={'Allow public user to upload'} />
|
||||
</div>
|
||||
<div class="my-3">
|
||||
<SettingSwitch bind:checked={allowUpload} title={'Allow public user to upload'} />
|
||||
</div>
|
||||
|
||||
<div class="text-sm">
|
||||
{#if editingLink}
|
||||
<p class="my-2 immich-form-label">
|
||||
<SettingSwitch
|
||||
bind:checked={shouldChangeExpirationTime}
|
||||
title={'Change expiration time'}
|
||||
/>
|
||||
</p>
|
||||
{:else}
|
||||
<p class="my-2 immich-form-label">Expire after</p>
|
||||
{/if}
|
||||
<div class="text-sm">
|
||||
{#if editingLink}
|
||||
<p class="my-2 immich-form-label">
|
||||
<SettingSwitch bind:checked={shouldChangeExpirationTime} title={'Change expiration time'} />
|
||||
</p>
|
||||
{:else}
|
||||
<p class="my-2 immich-form-label">Expire after</p>
|
||||
{/if}
|
||||
|
||||
<DropdownButton
|
||||
options={expiredDateOption}
|
||||
bind:selected={expirationTime}
|
||||
disabled={editingLink && !shouldChangeExpirationTime}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<DropdownButton
|
||||
options={expiredDateOption}
|
||||
bind:selected={expirationTime}
|
||||
disabled={editingLink && !shouldChangeExpirationTime}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
<hr />
|
||||
|
||||
<section class="m-6">
|
||||
{#if !sharedLink}
|
||||
{#if editingLink}
|
||||
<div class="flex justify-end">
|
||||
<Button size="sm" rounded="lg" on:click={handleEditLink}>Confirm</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex justify-end">
|
||||
<Button size="sm" rounded="lg" on:click={handleCreateSharedLink}>Create link</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex w-full gap-4">
|
||||
<input class="immich-form-input w-full" bind:value={sharedLink} disabled />
|
||||
<section class="m-6">
|
||||
{#if !sharedLink}
|
||||
{#if editingLink}
|
||||
<div class="flex justify-end">
|
||||
<Button size="sm" rounded="lg" on:click={handleEditLink}>Confirm</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex justify-end">
|
||||
<Button size="sm" rounded="lg" on:click={handleCreateSharedLink}>Create link</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex w-full gap-4">
|
||||
<input class="immich-form-input w-full" bind:value={sharedLink} disabled />
|
||||
|
||||
{#if canCopyImagesToClipboard}
|
||||
<Button on:click={() => handleCopy()}>Copy</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{#if canCopyImagesToClipboard}
|
||||
<Button on:click={() => handleCopy()}>Copy</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</BaseModal>
|
||||
|
|
|
|||
|
|
@ -1,39 +1,39 @@
|
|||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import ImmichLogo from './immich-logo.svelte';
|
||||
export let dropHandler: (event: DragEvent) => void;
|
||||
import { fade } from 'svelte/transition';
|
||||
import ImmichLogo from './immich-logo.svelte';
|
||||
export let dropHandler: (event: DragEvent) => void;
|
||||
|
||||
let dragStartTarget: EventTarget | null = null;
|
||||
let dragStartTarget: EventTarget | null = null;
|
||||
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
dragStartTarget = e.target;
|
||||
};
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
dragStartTarget = e.target;
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:body
|
||||
on:dragenter|stopPropagation|preventDefault={handleDragEnter}
|
||||
on:dragleave|stopPropagation|preventDefault={(e) => {
|
||||
if (dragStartTarget === e.target) {
|
||||
dragStartTarget = null;
|
||||
}
|
||||
}}
|
||||
on:drop|stopPropagation|preventDefault={(e) => {
|
||||
dragStartTarget = null;
|
||||
dropHandler(e);
|
||||
}}
|
||||
on:dragenter|stopPropagation|preventDefault={handleDragEnter}
|
||||
on:dragleave|stopPropagation|preventDefault={(e) => {
|
||||
if (dragStartTarget === e.target) {
|
||||
dragStartTarget = null;
|
||||
}
|
||||
}}
|
||||
on:drop|stopPropagation|preventDefault={(e) => {
|
||||
dragStartTarget = null;
|
||||
dropHandler(e);
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if dragStartTarget}
|
||||
<div
|
||||
class="fixed inset-0 w-full h-full z-[1000] flex flex-col items-center justify-center bg-gray-100/90 dark:bg-immich-dark-bg/90 text-immich-dark-gray dark:text-immich-gray"
|
||||
transition:fade={{ duration: 250 }}
|
||||
on:dragover={(e) => {
|
||||
// Prevent browser from opening the dropped file.
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<ImmichLogo class="animate-bounce w-48 m-16" />
|
||||
<div class="text-2xl">Drop files anywhere to upload</div>
|
||||
</div>
|
||||
<div
|
||||
class="fixed inset-0 w-full h-full z-[1000] flex flex-col items-center justify-center bg-gray-100/90 dark:bg-immich-dark-bg/90 text-immich-dark-gray dark:text-immich-gray"
|
||||
transition:fade={{ duration: 250 }}
|
||||
on:dragover={(e) => {
|
||||
// Prevent browser from opening the dropped file.
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<ImmichLogo class="animate-bounce w-48 m-16" />
|
||||
<div class="text-2xl">Drop files anywhere to upload</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,76 +1,76 @@
|
|||
<script lang="ts" context="module">
|
||||
export type ImmichDropDownOption = {
|
||||
default: string;
|
||||
options: string[];
|
||||
};
|
||||
export type ImmichDropDownOption = {
|
||||
default: string;
|
||||
options: string[];
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let options: ImmichDropDownOption;
|
||||
export let selected: string;
|
||||
export let disabled = false;
|
||||
export let options: ImmichDropDownOption;
|
||||
export let selected: string;
|
||||
export let disabled = false;
|
||||
|
||||
onMount(() => {
|
||||
selected = options.default;
|
||||
});
|
||||
onMount(() => {
|
||||
selected = options.default;
|
||||
});
|
||||
|
||||
export let isOpen = false;
|
||||
const toggle = () => (isOpen = !isOpen);
|
||||
export let isOpen = false;
|
||||
const toggle = () => (isOpen = !isOpen);
|
||||
</script>
|
||||
|
||||
<div id="immich-dropdown" class="relative">
|
||||
<button
|
||||
{disabled}
|
||||
on:click={toggle}
|
||||
aria-expanded={isOpen}
|
||||
class="bg-gray-200 w-full flex p-2 rounded-lg dark:bg-gray-600 place-items-center justify-between disabled:cursor-not-allowed dark:disabled:bg-gray-300 disabled:bg-gray-600"
|
||||
>
|
||||
<div>
|
||||
{selected}
|
||||
</div>
|
||||
<button
|
||||
{disabled}
|
||||
on:click={toggle}
|
||||
aria-expanded={isOpen}
|
||||
class="bg-gray-200 w-full flex p-2 rounded-lg dark:bg-gray-600 place-items-center justify-between disabled:cursor-not-allowed dark:disabled:bg-gray-300 disabled:bg-gray-600"
|
||||
>
|
||||
<div>
|
||||
{selected}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<svg
|
||||
style="tran"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div>
|
||||
<svg
|
||||
style="tran"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="flex flex-col mt-2 absolute w-full">
|
||||
{#each options.options as option}
|
||||
<button
|
||||
on:click={() => {
|
||||
selected = option;
|
||||
isOpen = false;
|
||||
}}
|
||||
class="bg-gray-200 dark:bg-gray-500 dark:hover:bg-gray-700 w-full flex p-2 hover:bg-gray-300 transition-all"
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if isOpen}
|
||||
<div class="flex flex-col mt-2 absolute w-full">
|
||||
{#each options.options as option}
|
||||
<button
|
||||
on:click={() => {
|
||||
selected = option;
|
||||
isOpen = false;
|
||||
}}
|
||||
class="bg-gray-200 dark:bg-gray-500 dark:hover:bg-gray-700 w-full flex p-2 hover:bg-gray-300 transition-all"
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
svg {
|
||||
transition: transform 0.2s ease-in;
|
||||
}
|
||||
svg {
|
||||
transition: transform 0.2s ease-in;
|
||||
}
|
||||
|
||||
[aria-expanded='true'] svg {
|
||||
transform: rotate(0.5turn);
|
||||
}
|
||||
[aria-expanded='true'] svg {
|
||||
transform: rotate(0.5turn);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,28 +1,27 @@
|
|||
<script lang="ts">
|
||||
import empty1Url from '$lib/assets/empty-1.svg';
|
||||
import empty1Url from '$lib/assets/empty-1.svg';
|
||||
|
||||
export let actionHandler: undefined | (() => Promise<void>) = undefined;
|
||||
export let text = '';
|
||||
export let alt = '';
|
||||
export let actionHandler: undefined | (() => Promise<void>) = undefined;
|
||||
export let text = '';
|
||||
export let alt = '';
|
||||
|
||||
let hoverClasses =
|
||||
'hover:bg-immich-primary/5 dark:hover:bg-immich-dark-primary/25 hover:cursor-pointer';
|
||||
let hoverClasses = 'hover:bg-immich-primary/5 dark:hover:bg-immich-dark-primary/25 hover:cursor-pointer';
|
||||
</script>
|
||||
|
||||
{#if actionHandler}
|
||||
<div
|
||||
on:click={actionHandler}
|
||||
on:keydown={actionHandler}
|
||||
class="border dark:border-immich-dark-gray {hoverClasses} p-5 w-[50%] m-auto mt-10 bg-gray-50 dark:bg-immich-dark-gray rounded-3xl flex flex-col place-content-center place-items-center"
|
||||
>
|
||||
<img src={empty1Url} {alt} width="500" draggable="false" />
|
||||
<p class="text-center text-immich-text-gray-500 dark:text-immich-dark-fg">{text}</p>
|
||||
</div>
|
||||
<div
|
||||
on:click={actionHandler}
|
||||
on:keydown={actionHandler}
|
||||
class="border dark:border-immich-dark-gray {hoverClasses} p-5 w-[50%] m-auto mt-10 bg-gray-50 dark:bg-immich-dark-gray rounded-3xl flex flex-col place-content-center place-items-center"
|
||||
>
|
||||
<img src={empty1Url} {alt} width="500" draggable="false" />
|
||||
<p class="text-center text-immich-text-gray-500 dark:text-immich-dark-fg">{text}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="border dark:border-immich-dark-gray p-5 w-[50%] m-auto mt-10 bg-gray-50 dark:bg-immich-dark-gray rounded-3xl flex flex-col place-content-center place-items-center"
|
||||
>
|
||||
<img src={empty1Url} {alt} width="500" draggable="false" />
|
||||
<p class="text-center text-immich-text-gray-500 dark:text-immich-dark-fg">{text}</p>
|
||||
</div>
|
||||
<div
|
||||
class="border dark:border-immich-dark-gray p-5 w-[50%] m-auto mt-10 bg-gray-50 dark:bg-immich-dark-gray rounded-3xl flex flex-col place-content-center place-items-center"
|
||||
>
|
||||
<img src={empty1Url} {alt} width="500" draggable="false" />
|
||||
<p class="text-center text-immich-text-gray-500 dark:text-immich-dark-fg">{text}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<script lang="ts">
|
||||
import IconAppleTouch180 from '$lib/assets/favicon/apple-icon-180.png';
|
||||
import Icon16 from '$lib/assets/favicon/favicon-16.png';
|
||||
import Icon32 from '$lib/assets/favicon/favicon-32.png';
|
||||
import Icon96 from '$lib/assets/favicon/favicon-96.png';
|
||||
import IconAppleTouch180 from '$lib/assets/favicon/apple-icon-180.png';
|
||||
import Icon16 from '$lib/assets/favicon/favicon-16.png';
|
||||
import Icon32 from '$lib/assets/favicon/favicon-32.png';
|
||||
import Icon96 from '$lib/assets/favicon/favicon-96.png';
|
||||
</script>
|
||||
|
||||
<link rel="icon" type="image/png" sizes="16x16" href={Icon16} />
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
<script lang="ts">
|
||||
import { clickOutside } from '../../utils/click-outside';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { clickOutside } from '../../utils/click-outside';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const dispatch = createEventDispatcher<{ clickOutside: void }>();
|
||||
const dispatch = createEventDispatcher<{ clickOutside: void }>();
|
||||
</script>
|
||||
|
||||
<section
|
||||
in:fade={{ duration: 100 }}
|
||||
out:fade={{ duration: 100 }}
|
||||
class="fixed left-0 top-0 w-screen h-screen bg-black/40 z-[990] flex place-items-center place-content-center"
|
||||
in:fade={{ duration: 100 }}
|
||||
out:fade={{ duration: 100 }}
|
||||
class="fixed left-0 top-0 w-screen h-screen bg-black/40 z-[990] flex place-items-center place-content-center"
|
||||
>
|
||||
<div class="z-[9999]" use:clickOutside on:outclick={() => dispatch('clickOutside')}>
|
||||
<slot />
|
||||
</div>
|
||||
<div class="z-[9999]" use:clickOutside on:outclick={() => dispatch('clickOutside')}>
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,29 +1,29 @@
|
|||
<script lang="ts">
|
||||
import ImmichLogo from './immich-logo.svelte';
|
||||
import ImmichLogo from './immich-logo.svelte';
|
||||
|
||||
export let title: string;
|
||||
export let showMessage = $$slots.message;
|
||||
export let title: string;
|
||||
export let showMessage = $$slots.message;
|
||||
</script>
|
||||
|
||||
<section class="min-h-screen w-screen flex place-items-center place-content-center p-4">
|
||||
<div
|
||||
class="flex flex-col gap-4 border bg-white dark:bg-immich-dark-gray dark:border-immich-dark-gray p-8 shadow-sm w-full max-w-lg rounded-3xl"
|
||||
>
|
||||
<div class="flex flex-col place-items-center place-content-center gap-4 py-4">
|
||||
<ImmichLogo class="h-24 w-24" />
|
||||
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col gap-4 border bg-white dark:bg-immich-dark-gray dark:border-immich-dark-gray p-8 shadow-sm w-full max-w-lg rounded-3xl"
|
||||
>
|
||||
<div class="flex flex-col place-items-center place-content-center gap-4 py-4">
|
||||
<ImmichLogo class="h-24 w-24" />
|
||||
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{#if showMessage}
|
||||
<div
|
||||
class="text-sm rounded-xl p-4 text-immich-primary dark:text-immich-dark-primary font-medium bg-immich-primary/5 dark:border-immich-dark-bg w-full border-immich-primary border-2"
|
||||
>
|
||||
<slot name="message" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if showMessage}
|
||||
<div
|
||||
class="text-sm rounded-xl p-4 text-immich-primary dark:text-immich-dark-primary font-medium bg-immich-primary/5 dark:border-immich-dark-bg w-full border-immich-primary border-2"
|
||||
>
|
||||
<slot name="message" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,147 +1,142 @@
|
|||
<script lang="ts" context="module">
|
||||
export type ViewFrom =
|
||||
| 'archive-page'
|
||||
| 'album-page'
|
||||
| 'favorites-page'
|
||||
| 'search-page'
|
||||
| 'shared-link-page';
|
||||
export type ViewFrom = 'archive-page' | 'album-page' | 'favorites-page' | 'search-page' | 'shared-link-page';
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { AssetResponseDto, SharedLinkResponseDto, ThumbnailFormat } from '@api';
|
||||
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { archivedAsset } from '$lib/stores/archived-asset.store';
|
||||
import { page } from '$app/stores';
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { AssetResponseDto, SharedLinkResponseDto, ThumbnailFormat } from '@api';
|
||||
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { archivedAsset } from '$lib/stores/archived-asset.store';
|
||||
|
||||
export let assets: AssetResponseDto[];
|
||||
export let sharedLink: SharedLinkResponseDto | undefined = undefined;
|
||||
export let selectedAssets: Set<AssetResponseDto> = new Set();
|
||||
export let disableAssetSelect = false;
|
||||
export let viewFrom: ViewFrom;
|
||||
export let showArchiveIcon = false;
|
||||
export let assets: AssetResponseDto[];
|
||||
export let sharedLink: SharedLinkResponseDto | undefined = undefined;
|
||||
export let selectedAssets: Set<AssetResponseDto> = new Set();
|
||||
export let disableAssetSelect = false;
|
||||
export let viewFrom: ViewFrom;
|
||||
export let showArchiveIcon = false;
|
||||
|
||||
let isShowAssetViewer = false;
|
||||
let isShowAssetViewer = false;
|
||||
|
||||
let selectedAsset: AssetResponseDto;
|
||||
let currentViewAssetIndex = 0;
|
||||
let selectedAsset: AssetResponseDto;
|
||||
let currentViewAssetIndex = 0;
|
||||
|
||||
let viewWidth: number;
|
||||
let thumbnailSize = 300;
|
||||
let viewWidth: number;
|
||||
let thumbnailSize = 300;
|
||||
|
||||
$: isMultiSelectionMode = selectedAssets.size > 0;
|
||||
$: isMultiSelectionMode = selectedAssets.size > 0;
|
||||
|
||||
$: {
|
||||
if (assets.length < 6) {
|
||||
thumbnailSize = Math.min(320, Math.floor(viewWidth / assets.length - assets.length));
|
||||
} else {
|
||||
if (viewWidth > 600) thumbnailSize = Math.floor(viewWidth / 7 - 7);
|
||||
else if (viewWidth > 400) thumbnailSize = Math.floor(viewWidth / 4 - 6);
|
||||
else if (viewWidth > 300) thumbnailSize = Math.floor(viewWidth / 2 - 6);
|
||||
else if (viewWidth > 200) thumbnailSize = Math.floor(viewWidth / 2 - 6);
|
||||
else if (viewWidth > 100) thumbnailSize = Math.floor(viewWidth / 1 - 6);
|
||||
}
|
||||
}
|
||||
$: {
|
||||
if (assets.length < 6) {
|
||||
thumbnailSize = Math.min(320, Math.floor(viewWidth / assets.length - assets.length));
|
||||
} else {
|
||||
if (viewWidth > 600) thumbnailSize = Math.floor(viewWidth / 7 - 7);
|
||||
else if (viewWidth > 400) thumbnailSize = Math.floor(viewWidth / 4 - 6);
|
||||
else if (viewWidth > 300) thumbnailSize = Math.floor(viewWidth / 2 - 6);
|
||||
else if (viewWidth > 200) thumbnailSize = Math.floor(viewWidth / 2 - 6);
|
||||
else if (viewWidth > 100) thumbnailSize = Math.floor(viewWidth / 1 - 6);
|
||||
}
|
||||
}
|
||||
|
||||
const viewAssetHandler = (event: CustomEvent) => {
|
||||
const { asset }: { asset: AssetResponseDto } = event.detail;
|
||||
const viewAssetHandler = (event: CustomEvent) => {
|
||||
const { asset }: { asset: AssetResponseDto } = event.detail;
|
||||
|
||||
currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
|
||||
selectedAsset = assets[currentViewAssetIndex];
|
||||
isShowAssetViewer = true;
|
||||
pushState(selectedAsset.id);
|
||||
};
|
||||
currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
|
||||
selectedAsset = assets[currentViewAssetIndex];
|
||||
isShowAssetViewer = true;
|
||||
pushState(selectedAsset.id);
|
||||
};
|
||||
|
||||
const selectAssetHandler = (event: CustomEvent) => {
|
||||
const { asset }: { asset: AssetResponseDto } = event.detail;
|
||||
let temp = new Set(selectedAssets);
|
||||
const selectAssetHandler = (event: CustomEvent) => {
|
||||
const { asset }: { asset: AssetResponseDto } = event.detail;
|
||||
let temp = new Set(selectedAssets);
|
||||
|
||||
if (selectedAssets.has(asset)) {
|
||||
temp.delete(asset);
|
||||
} else {
|
||||
temp.add(asset);
|
||||
}
|
||||
if (selectedAssets.has(asset)) {
|
||||
temp.delete(asset);
|
||||
} else {
|
||||
temp.add(asset);
|
||||
}
|
||||
|
||||
selectedAssets = temp;
|
||||
};
|
||||
selectedAssets = temp;
|
||||
};
|
||||
|
||||
const navigateAssetForward = () => {
|
||||
try {
|
||||
if (currentViewAssetIndex < assets.length - 1) {
|
||||
currentViewAssetIndex++;
|
||||
selectedAsset = assets[currentViewAssetIndex];
|
||||
pushState(selectedAsset.id);
|
||||
}
|
||||
} catch (e) {
|
||||
handleError(e, 'Cannot navigate to the next asset');
|
||||
}
|
||||
};
|
||||
const navigateAssetForward = () => {
|
||||
try {
|
||||
if (currentViewAssetIndex < assets.length - 1) {
|
||||
currentViewAssetIndex++;
|
||||
selectedAsset = assets[currentViewAssetIndex];
|
||||
pushState(selectedAsset.id);
|
||||
}
|
||||
} catch (e) {
|
||||
handleError(e, 'Cannot navigate to the next asset');
|
||||
}
|
||||
};
|
||||
|
||||
const navigateAssetBackward = () => {
|
||||
try {
|
||||
if (currentViewAssetIndex > 0) {
|
||||
currentViewAssetIndex--;
|
||||
selectedAsset = assets[currentViewAssetIndex];
|
||||
pushState(selectedAsset.id);
|
||||
}
|
||||
} catch (e) {
|
||||
handleError(e, 'Cannot navigate to previous asset');
|
||||
}
|
||||
};
|
||||
const navigateAssetBackward = () => {
|
||||
try {
|
||||
if (currentViewAssetIndex > 0) {
|
||||
currentViewAssetIndex--;
|
||||
selectedAsset = assets[currentViewAssetIndex];
|
||||
pushState(selectedAsset.id);
|
||||
}
|
||||
} catch (e) {
|
||||
handleError(e, 'Cannot navigate to previous asset');
|
||||
}
|
||||
};
|
||||
|
||||
const pushState = (assetId: string) => {
|
||||
// add a URL to the browser's history
|
||||
// changes the current URL in the address bar but doesn't perform any SvelteKit navigation
|
||||
history.pushState(null, '', `${$page.url.pathname}/photos/${assetId}`);
|
||||
};
|
||||
const pushState = (assetId: string) => {
|
||||
// add a URL to the browser's history
|
||||
// changes the current URL in the address bar but doesn't perform any SvelteKit navigation
|
||||
history.pushState(null, '', `${$page.url.pathname}/photos/${assetId}`);
|
||||
};
|
||||
|
||||
const closeViewer = () => {
|
||||
isShowAssetViewer = false;
|
||||
history.pushState(null, '', `${$page.url.pathname}`);
|
||||
};
|
||||
const closeViewer = () => {
|
||||
isShowAssetViewer = false;
|
||||
history.pushState(null, '', `${$page.url.pathname}`);
|
||||
};
|
||||
|
||||
const handleUnarchivedSuccess = (event: CustomEvent) => {
|
||||
const asset = event.detail as AssetResponseDto;
|
||||
switch (viewFrom) {
|
||||
case 'archive-page':
|
||||
$archivedAsset = $archivedAsset.filter((a) => a.id != asset.id);
|
||||
navigateAssetForward();
|
||||
break;
|
||||
}
|
||||
};
|
||||
const handleUnarchivedSuccess = (event: CustomEvent) => {
|
||||
const asset = event.detail as AssetResponseDto;
|
||||
switch (viewFrom) {
|
||||
case 'archive-page':
|
||||
$archivedAsset = $archivedAsset.filter((a) => a.id != asset.id);
|
||||
navigateAssetForward();
|
||||
break;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if assets.length > 0}
|
||||
<div class="flex flex-wrap gap-1 w-full pb-20" bind:clientWidth={viewWidth}>
|
||||
{#each assets as asset (asset.id)}
|
||||
<div animate:flip={{ duration: 500 }}>
|
||||
<Thumbnail
|
||||
{asset}
|
||||
{thumbnailSize}
|
||||
readonly={disableAssetSelect}
|
||||
publicSharedKey={sharedLink?.key}
|
||||
format={assets.length < 7 ? ThumbnailFormat.Jpeg : ThumbnailFormat.Webp}
|
||||
on:click={(e) => (isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e))}
|
||||
on:select={selectAssetHandler}
|
||||
selected={selectedAssets.has(asset)}
|
||||
{showArchiveIcon}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1 w-full pb-20" bind:clientWidth={viewWidth}>
|
||||
{#each assets as asset (asset.id)}
|
||||
<div animate:flip={{ duration: 500 }}>
|
||||
<Thumbnail
|
||||
{asset}
|
||||
{thumbnailSize}
|
||||
readonly={disableAssetSelect}
|
||||
publicSharedKey={sharedLink?.key}
|
||||
format={assets.length < 7 ? ThumbnailFormat.Jpeg : ThumbnailFormat.Webp}
|
||||
on:click={(e) => (isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e))}
|
||||
on:select={selectAssetHandler}
|
||||
selected={selectedAssets.has(asset)}
|
||||
{showArchiveIcon}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Overlay Asset Viewer -->
|
||||
{#if isShowAssetViewer}
|
||||
<AssetViewer
|
||||
asset={selectedAsset}
|
||||
publicSharedKey={sharedLink?.key}
|
||||
{sharedLink}
|
||||
on:navigate-previous={navigateAssetBackward}
|
||||
on:navigate-next={navigateAssetForward}
|
||||
on:close={closeViewer}
|
||||
on:unarchived={handleUnarchivedSuccess}
|
||||
/>
|
||||
<AssetViewer
|
||||
asset={selectedAsset}
|
||||
publicSharedKey={sharedLink?.key}
|
||||
{sharedLink}
|
||||
on:navigate-previous={navigateAssetBackward}
|
||||
on:navigate-next={navigateAssetForward}
|
||||
on:close={closeViewer}
|
||||
on:unarchived={handleUnarchivedSuccess}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import immichLogoUrl from '$lib/assets/immich-logo.svg';
|
||||
import immichLogoUrl from '$lib/assets/immich-logo.svg';
|
||||
|
||||
export let draggable = false;
|
||||
export let draggable = false;
|
||||
</script>
|
||||
|
||||
<img src={immichLogoUrl} alt="Immich Logo" {draggable} {...$$restProps} />
|
||||
|
|
|
|||
|
|
@ -1,35 +1,35 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { Control, type ControlPosition } from 'leaflet';
|
||||
import { getMapContext } from './map.svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { Control, type ControlPosition } from 'leaflet';
|
||||
import { getMapContext } from './map.svelte';
|
||||
|
||||
export let position: ControlPosition | undefined = undefined;
|
||||
let className: string | undefined = undefined;
|
||||
export { className as class };
|
||||
export let position: ControlPosition | undefined = undefined;
|
||||
let className: string | undefined = undefined;
|
||||
export { className as class };
|
||||
|
||||
let control: Control;
|
||||
let target: HTMLDivElement;
|
||||
let control: Control;
|
||||
let target: HTMLDivElement;
|
||||
|
||||
const map = getMapContext();
|
||||
const map = getMapContext();
|
||||
|
||||
onMount(() => {
|
||||
const ControlClass = Control.extend({
|
||||
position,
|
||||
onAdd: () => target
|
||||
});
|
||||
onMount(() => {
|
||||
const ControlClass = Control.extend({
|
||||
position,
|
||||
onAdd: () => target,
|
||||
});
|
||||
|
||||
control = new ControlClass().addTo(map);
|
||||
});
|
||||
control = new ControlClass().addTo(map);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
control.remove();
|
||||
});
|
||||
onDestroy(() => {
|
||||
control.remove();
|
||||
});
|
||||
|
||||
$: if (control && position) {
|
||||
control.setPosition(position);
|
||||
}
|
||||
$: if (control && position) {
|
||||
control.setPosition(position);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={target} class={className}>
|
||||
<slot />
|
||||
<slot />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export { default as AssetMarkerCluster } from './marker-cluster/asset-marker-cluster.svelte';
|
||||
export { default as Control } from './control.svelte';
|
||||
export { default as Map } from './map.svelte';
|
||||
export { default as AssetMarkerCluster } from './marker-cluster/asset-marker-cluster.svelte';
|
||||
export { default as Marker } from './marker.svelte';
|
||||
export { default as TileLayer } from './tile-layer.svelte';
|
||||
|
|
|
|||
|
|
@ -1,50 +1,50 @@
|
|||
<script lang="ts" context="module">
|
||||
import { createContext } from '$lib/utils/context';
|
||||
import { createContext } from '$lib/utils/context';
|
||||
|
||||
const { get: getContext, set: setMapContext } = createContext<() => Map>();
|
||||
const { get: getContext, set: setMapContext } = createContext<() => Map>();
|
||||
|
||||
export const getMapContext = () => {
|
||||
const getMap = getContext();
|
||||
return getMap();
|
||||
};
|
||||
export const getMapContext = () => {
|
||||
const getMap = getContext();
|
||||
return getMap();
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { Map, type LatLngExpression, type MapOptions } from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { Map, type LatLngExpression, type MapOptions } from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
export let center: LatLngExpression;
|
||||
export let zoom: number;
|
||||
export let options: MapOptions | undefined = undefined;
|
||||
export let allowDarkMode = false;
|
||||
let container: HTMLDivElement;
|
||||
let map: Map;
|
||||
export let center: LatLngExpression;
|
||||
export let zoom: number;
|
||||
export let options: MapOptions | undefined = undefined;
|
||||
export let allowDarkMode = false;
|
||||
let container: HTMLDivElement;
|
||||
let map: Map;
|
||||
|
||||
setMapContext(() => map);
|
||||
setMapContext(() => map);
|
||||
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
map = new Map(container, options);
|
||||
}
|
||||
});
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
map = new Map(container, options);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (map) map.remove();
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (map) map.remove();
|
||||
});
|
||||
|
||||
$: if (map) map.setView(center, zoom);
|
||||
$: if (map) map.setView(center, zoom);
|
||||
</script>
|
||||
|
||||
<div bind:this={container} class="w-full h-full" class:map-dark={allowDarkMode}>
|
||||
{#if map}
|
||||
<slot />
|
||||
{/if}
|
||||
{#if map}
|
||||
<slot />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.dark) .map-dark :global(.leaflet-layer) {
|
||||
filter: invert(100%) brightness(130%) saturate(0%);
|
||||
}
|
||||
:global(.dark) .map-dark :global(.leaflet-layer) {
|
||||
filter: invert(100%) brightness(130%) saturate(0%);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,32 +1,31 @@
|
|||
.asset-marker-icon {
|
||||
@apply rounded-full;
|
||||
@apply object-cover;
|
||||
@apply border;
|
||||
@apply border-immich-primary;
|
||||
@apply transition-all;
|
||||
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, rgba(0, 0, 0, 0.07) 0px 2px 4px,
|
||||
rgba(0, 0, 0, 0.07) 0px 4px 8px, rgba(0, 0, 0, 0.07) 0px 8px 16px,
|
||||
rgba(0, 0, 0, 0.07) 0px 16px 32px, rgba(0, 0, 0, 0.07) 0px 32px 64px;
|
||||
@apply rounded-full;
|
||||
@apply object-cover;
|
||||
@apply border;
|
||||
@apply border-immich-primary;
|
||||
@apply transition-all;
|
||||
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, rgba(0, 0, 0, 0.07) 0px 2px 4px, rgba(0, 0, 0, 0.07) 0px 4px 8px,
|
||||
rgba(0, 0, 0, 0.07) 0px 8px 16px, rgba(0, 0, 0, 0.07) 0px 16px 32px, rgba(0, 0, 0, 0.07) 0px 32px 64px;
|
||||
}
|
||||
|
||||
.marker-cluster-icon {
|
||||
@apply h-full;
|
||||
@apply w-full;
|
||||
@apply flex;
|
||||
@apply justify-center;
|
||||
@apply items-center;
|
||||
@apply rounded-full;
|
||||
@apply font-bold;
|
||||
@apply bg-violet-50;
|
||||
@apply border;
|
||||
@apply border-immich-primary;
|
||||
@apply text-immich-primary;
|
||||
box-shadow: rgba(5, 5, 122, 0.12) 0px 2px 4px 0px, rgba(4, 4, 230, 0.32) 0px 2px 16px 0px;
|
||||
@apply h-full;
|
||||
@apply w-full;
|
||||
@apply flex;
|
||||
@apply justify-center;
|
||||
@apply items-center;
|
||||
@apply rounded-full;
|
||||
@apply font-bold;
|
||||
@apply bg-violet-50;
|
||||
@apply border;
|
||||
@apply border-immich-primary;
|
||||
@apply text-immich-primary;
|
||||
box-shadow: rgba(5, 5, 122, 0.12) 0px 2px 4px 0px, rgba(4, 4, 230, 0.32) 0px 2px 16px 0px;
|
||||
}
|
||||
|
||||
.dark .map-dark .marker-cluster-icon {
|
||||
@apply bg-blue-200;
|
||||
@apply text-black;
|
||||
@apply border-blue-200;
|
||||
box-shadow: none;
|
||||
@apply bg-blue-200;
|
||||
@apply text-black;
|
||||
@apply border-blue-200;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,104 +1,102 @@
|
|||
<script lang="ts" context="module">
|
||||
import { createContext } from '$lib/utils/context';
|
||||
import { MarkerClusterGroup } from 'leaflet';
|
||||
import { createContext } from '$lib/utils/context';
|
||||
import { MarkerClusterGroup } from 'leaflet';
|
||||
|
||||
const { get: getContext, set: setClusterContext } = createContext<() => MarkerClusterGroup>();
|
||||
const { get: getContext, set: setClusterContext } = createContext<() => MarkerClusterGroup>();
|
||||
|
||||
export const getClusterContext = () => {
|
||||
return getContext()();
|
||||
};
|
||||
export const getClusterContext = () => {
|
||||
return getContext()();
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { MapMarkerResponseDto } from '@api';
|
||||
import { DivIcon, LeafletEvent, LeafletMouseEvent, MarkerCluster, Point } from 'leaflet';
|
||||
import 'leaflet.markercluster';
|
||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
|
||||
import { getMapContext } from '../map.svelte';
|
||||
import AssetMarker from './asset-marker';
|
||||
import './asset-marker-cluster.css';
|
||||
import type { MapMarkerResponseDto } from '@api';
|
||||
import { DivIcon, LeafletEvent, LeafletMouseEvent, MarkerCluster, Point } from 'leaflet';
|
||||
import 'leaflet.markercluster';
|
||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
|
||||
import { getMapContext } from '../map.svelte';
|
||||
import AssetMarker from './asset-marker';
|
||||
import './asset-marker-cluster.css';
|
||||
|
||||
export let markers: MapMarkerResponseDto[];
|
||||
export let spiderfyLimit = 10;
|
||||
let cluster: MarkerClusterGroup;
|
||||
export let markers: MapMarkerResponseDto[];
|
||||
export let spiderfyLimit = 10;
|
||||
let cluster: MarkerClusterGroup;
|
||||
|
||||
const map = getMapContext();
|
||||
const dispatch = createEventDispatcher<{
|
||||
view: { assetIds: string[]; activeAssetIndex: number };
|
||||
}>();
|
||||
const map = getMapContext();
|
||||
const dispatch = createEventDispatcher<{
|
||||
view: { assetIds: string[]; activeAssetIndex: number };
|
||||
}>();
|
||||
|
||||
setClusterContext(() => cluster);
|
||||
setClusterContext(() => cluster);
|
||||
|
||||
onMount(() => {
|
||||
cluster = new MarkerClusterGroup({
|
||||
showCoverageOnHover: false,
|
||||
zoomToBoundsOnClick: false,
|
||||
spiderfyOnMaxZoom: false,
|
||||
maxClusterRadius: (zoom) => 80 - zoom * 2,
|
||||
spiderLegPolylineOptions: { opacity: 0 },
|
||||
spiderfyDistanceMultiplier: 3,
|
||||
iconCreateFunction: (options) => {
|
||||
const childCount = options.getChildCount();
|
||||
const iconSize = childCount > spiderfyLimit ? 45 : 40;
|
||||
onMount(() => {
|
||||
cluster = new MarkerClusterGroup({
|
||||
showCoverageOnHover: false,
|
||||
zoomToBoundsOnClick: false,
|
||||
spiderfyOnMaxZoom: false,
|
||||
maxClusterRadius: (zoom) => 80 - zoom * 2,
|
||||
spiderLegPolylineOptions: { opacity: 0 },
|
||||
spiderfyDistanceMultiplier: 3,
|
||||
iconCreateFunction: (options) => {
|
||||
const childCount = options.getChildCount();
|
||||
const iconSize = childCount > spiderfyLimit ? 45 : 40;
|
||||
|
||||
return new DivIcon({
|
||||
html: `<div class="marker-cluster-icon">${childCount}</div>`,
|
||||
className: '',
|
||||
iconSize: new Point(iconSize, iconSize)
|
||||
});
|
||||
}
|
||||
});
|
||||
return new DivIcon({
|
||||
html: `<div class="marker-cluster-icon">${childCount}</div>`,
|
||||
className: '',
|
||||
iconSize: new Point(iconSize, iconSize),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
cluster.on('clusterclick', (event: LeafletEvent) => {
|
||||
const markerCluster: MarkerCluster = event.sourceTarget;
|
||||
const childCount = markerCluster.getChildCount();
|
||||
cluster.on('clusterclick', (event: LeafletEvent) => {
|
||||
const markerCluster: MarkerCluster = event.sourceTarget;
|
||||
const childCount = markerCluster.getChildCount();
|
||||
|
||||
if (childCount > spiderfyLimit) {
|
||||
const markers = markerCluster.getAllChildMarkers() as AssetMarker[];
|
||||
onView(markers, markers[0].id);
|
||||
} else {
|
||||
markerCluster.spiderfy();
|
||||
}
|
||||
});
|
||||
if (childCount > spiderfyLimit) {
|
||||
const markers = markerCluster.getAllChildMarkers() as AssetMarker[];
|
||||
onView(markers, markers[0].id);
|
||||
} else {
|
||||
markerCluster.spiderfy();
|
||||
}
|
||||
});
|
||||
|
||||
cluster.on('click', (event: LeafletMouseEvent) => {
|
||||
const marker: AssetMarker = event.sourceTarget;
|
||||
const markerCluster = getClusterByMarker(marker);
|
||||
const markers = markerCluster
|
||||
? (markerCluster.getAllChildMarkers() as AssetMarker[])
|
||||
: [marker];
|
||||
cluster.on('click', (event: LeafletMouseEvent) => {
|
||||
const marker: AssetMarker = event.sourceTarget;
|
||||
const markerCluster = getClusterByMarker(marker);
|
||||
const markers = markerCluster ? (markerCluster.getAllChildMarkers() as AssetMarker[]) : [marker];
|
||||
|
||||
onView(markers, marker.id);
|
||||
});
|
||||
onView(markers, marker.id);
|
||||
});
|
||||
|
||||
map.addLayer(cluster);
|
||||
});
|
||||
map.addLayer(cluster);
|
||||
});
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
const getClusterByMarker = (marker: any): MarkerCluster | undefined => {
|
||||
const mapZoom = map.getZoom();
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
const getClusterByMarker = (marker: any): MarkerCluster | undefined => {
|
||||
const mapZoom = map.getZoom();
|
||||
|
||||
while (marker && marker._zoom !== mapZoom) {
|
||||
marker = marker.__parent;
|
||||
}
|
||||
while (marker && marker._zoom !== mapZoom) {
|
||||
marker = marker.__parent;
|
||||
}
|
||||
|
||||
return marker;
|
||||
};
|
||||
return marker;
|
||||
};
|
||||
|
||||
const onView = (markers: AssetMarker[], activeAssetId: string) => {
|
||||
const assetIds = markers.map((marker) => marker.id);
|
||||
const activeAssetIndex = assetIds.indexOf(activeAssetId) || 0;
|
||||
dispatch('view', { assetIds, activeAssetIndex });
|
||||
};
|
||||
const onView = (markers: AssetMarker[], activeAssetId: string) => {
|
||||
const assetIds = markers.map((marker) => marker.id);
|
||||
const activeAssetIndex = assetIds.indexOf(activeAssetId) || 0;
|
||||
dispatch('view', { assetIds, activeAssetIndex });
|
||||
};
|
||||
|
||||
$: if (cluster) {
|
||||
const leafletMarkers = markers.map((marker) => new AssetMarker(marker));
|
||||
$: if (cluster) {
|
||||
const leafletMarkers = markers.map((marker) => new AssetMarker(marker));
|
||||
|
||||
cluster.clearLayers();
|
||||
cluster.addLayers(leafletMarkers);
|
||||
}
|
||||
cluster.clearLayers();
|
||||
cluster.addLayers(leafletMarkers);
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (cluster) cluster.remove();
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (cluster) cluster.remove();
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,37 +1,37 @@
|
|||
import { MapMarkerResponseDto, api } from '@api';
|
||||
import { Marker, Map, Icon } from 'leaflet';
|
||||
import { api, MapMarkerResponseDto } from '@api';
|
||||
import { Icon, Map, Marker } from 'leaflet';
|
||||
|
||||
export default class AssetMarker extends Marker {
|
||||
id: string;
|
||||
private iconCreated = false;
|
||||
id: string;
|
||||
private iconCreated = false;
|
||||
|
||||
constructor(marker: MapMarkerResponseDto) {
|
||||
super([marker.lat, marker.lon]);
|
||||
this.id = marker.id;
|
||||
}
|
||||
constructor(marker: MapMarkerResponseDto) {
|
||||
super([marker.lat, marker.lon]);
|
||||
this.id = marker.id;
|
||||
}
|
||||
|
||||
onAdd(map: Map) {
|
||||
// Set icon when the marker gets actually added to the map. This only
|
||||
// gets called for individual assets and when selecting a cluster, so
|
||||
// creating an icon for every marker in advance is pretty wasteful.
|
||||
if (!this.iconCreated) {
|
||||
this.iconCreated = true;
|
||||
this.setIcon(this.getIcon());
|
||||
}
|
||||
onAdd(map: Map) {
|
||||
// Set icon when the marker gets actually added to the map. This only
|
||||
// gets called for individual assets and when selecting a cluster, so
|
||||
// creating an icon for every marker in advance is pretty wasteful.
|
||||
if (!this.iconCreated) {
|
||||
this.iconCreated = true;
|
||||
this.setIcon(this.getIcon());
|
||||
}
|
||||
|
||||
return super.onAdd(map);
|
||||
}
|
||||
return super.onAdd(map);
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
return new Icon({
|
||||
iconUrl: api.getAssetThumbnailUrl(this.id),
|
||||
iconRetinaUrl: api.getAssetThumbnailUrl(this.id),
|
||||
iconSize: [60, 60],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
tooltipAnchor: [16, -28],
|
||||
shadowSize: [41, 41],
|
||||
className: 'asset-marker-icon'
|
||||
});
|
||||
}
|
||||
getIcon() {
|
||||
return new Icon({
|
||||
iconUrl: api.getAssetThumbnailUrl(this.id),
|
||||
iconRetinaUrl: api.getAssetThumbnailUrl(this.id),
|
||||
iconSize: [60, 60],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
tooltipAnchor: [16, -28],
|
||||
shadowSize: [41, 41],
|
||||
className: 'asset-marker-icon',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,46 +1,46 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { Marker, Icon, type LatLngExpression, type Content } from 'leaflet';
|
||||
import { getMapContext } from './map.svelte';
|
||||
import iconUrl from 'leaflet/dist/images/marker-icon.png';
|
||||
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
|
||||
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { Marker, Icon, type LatLngExpression, type Content } from 'leaflet';
|
||||
import { getMapContext } from './map.svelte';
|
||||
import iconUrl from 'leaflet/dist/images/marker-icon.png';
|
||||
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
|
||||
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
|
||||
|
||||
export let latlng: LatLngExpression;
|
||||
export let popupContent: Content | undefined = undefined;
|
||||
let marker: Marker;
|
||||
export let latlng: LatLngExpression;
|
||||
export let popupContent: Content | undefined = undefined;
|
||||
let marker: Marker;
|
||||
|
||||
const defaultIcon = new Icon({
|
||||
iconUrl,
|
||||
iconRetinaUrl,
|
||||
shadowUrl,
|
||||
const defaultIcon = new Icon({
|
||||
iconUrl,
|
||||
iconRetinaUrl,
|
||||
shadowUrl,
|
||||
|
||||
// Default values from Leaflet
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
tooltipAnchor: [16, -28],
|
||||
shadowSize: [41, 41]
|
||||
});
|
||||
const map = getMapContext();
|
||||
// Default values from Leaflet
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
tooltipAnchor: [16, -28],
|
||||
shadowSize: [41, 41],
|
||||
});
|
||||
const map = getMapContext();
|
||||
|
||||
onMount(() => {
|
||||
marker = new Marker(latlng, {
|
||||
icon: defaultIcon
|
||||
}).addTo(map);
|
||||
});
|
||||
onMount(() => {
|
||||
marker = new Marker(latlng, {
|
||||
icon: defaultIcon,
|
||||
}).addTo(map);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (marker) marker.remove();
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (marker) marker.remove();
|
||||
});
|
||||
|
||||
$: if (marker) {
|
||||
marker.setLatLng(latlng);
|
||||
$: if (marker) {
|
||||
marker.setLatLng(latlng);
|
||||
|
||||
if (popupContent) {
|
||||
marker.bindPopup(popupContent);
|
||||
} else {
|
||||
marker.unbindPopup();
|
||||
}
|
||||
}
|
||||
if (popupContent) {
|
||||
marker.bindPopup(popupContent);
|
||||
} else {
|
||||
marker.unbindPopup();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { TileLayer, type TileLayerOptions } from 'leaflet';
|
||||
import { getMapContext } from './map.svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { TileLayer, type TileLayerOptions } from 'leaflet';
|
||||
import { getMapContext } from './map.svelte';
|
||||
|
||||
export let urlTemplate: string;
|
||||
export let options: TileLayerOptions | undefined = undefined;
|
||||
export let urlTemplate: string;
|
||||
export let options: TileLayerOptions | undefined = undefined;
|
||||
|
||||
let tileLayer: TileLayer;
|
||||
let tileLayer: TileLayer;
|
||||
|
||||
const map = getMapContext();
|
||||
const map = getMapContext();
|
||||
|
||||
onMount(() => {
|
||||
tileLayer = new TileLayer(urlTemplate, options).addTo(map);
|
||||
});
|
||||
onMount(() => {
|
||||
tileLayer = new TileLayer(urlTemplate, options).addTo(map);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (tileLayer) tileLayer.remove();
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (tileLayer) tileLayer.remove();
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
<div>
|
||||
<svg
|
||||
role="status"
|
||||
class={`w-[24px] h-[24px] text-gray-400 animate-spin dark:text-gray-600 fill-immich-primary`}
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
role="status"
|
||||
class={`w-[24px] h-[24px] text-gray-400 animate-spin dark:text-gray-600 fill-immich-primary`}
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,54 +1,54 @@
|
|||
<script lang="ts">
|
||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import type { UserResponseDto } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import Cog from 'svelte-material-icons/Cog.svelte';
|
||||
import Logout from 'svelte-material-icons/Logout.svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import UserAvatar from '../user-avatar.svelte';
|
||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import type { UserResponseDto } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import Cog from 'svelte-material-icons/Cog.svelte';
|
||||
import Logout from 'svelte-material-icons/Logout.svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import UserAvatar from '../user-avatar.svelte';
|
||||
|
||||
export let user: UserResponseDto;
|
||||
export let user: UserResponseDto;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
<div
|
||||
in:fade={{ duration: 100 }}
|
||||
out:fade={{ duration: 100 }}
|
||||
id="account-info-panel"
|
||||
class="absolute right-[25px] top-[75px] bg-gray-200 dark:bg-immich-dark-gray dark:border dark:border-immich-dark-gray shadow-lg rounded-3xl w-[360px] z-[100]"
|
||||
in:fade={{ duration: 100 }}
|
||||
out:fade={{ duration: 100 }}
|
||||
id="account-info-panel"
|
||||
class="absolute right-[25px] top-[75px] bg-gray-200 dark:bg-immich-dark-gray dark:border dark:border-immich-dark-gray shadow-lg rounded-3xl w-[360px] z-[100]"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center gap-4 bg-white dark:bg-immich-dark-primary/10 rounded-3xl mx-4 mt-4 p-4"
|
||||
>
|
||||
<UserAvatar size="lg" {user} />
|
||||
<div
|
||||
class="flex flex-col items-center justify-center gap-4 bg-white dark:bg-immich-dark-primary/10 rounded-3xl mx-4 mt-4 p-4"
|
||||
>
|
||||
<UserAvatar size="lg" {user} />
|
||||
|
||||
<div>
|
||||
<p class="text-lg text-immich-primary dark:text-immich-dark-primary font-medium text-center">
|
||||
{user.firstName}
|
||||
{user.lastName}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-immich-dark-fg">{user.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-lg text-immich-primary dark:text-immich-dark-primary font-medium text-center">
|
||||
{user.firstName}
|
||||
{user.lastName}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-immich-dark-fg">{user.email}</p>
|
||||
</div>
|
||||
|
||||
<a href={AppRoute.USER_SETTINGS} on:click={() => dispatch('close')}>
|
||||
<Button color="dark-gray" size="sm" shadow={false} border>
|
||||
<div class="flex gap-2 place-items-center place-content-center px-2">
|
||||
<Cog size="18" />
|
||||
Account Settings
|
||||
</div>
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
<a href={AppRoute.USER_SETTINGS} on:click={() => dispatch('close')}>
|
||||
<Button color="dark-gray" size="sm" shadow={false} border>
|
||||
<div class="flex gap-2 place-items-center place-content-center px-2">
|
||||
<Cog size="18" />
|
||||
Account Settings
|
||||
</div>
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 flex flex-col">
|
||||
<button
|
||||
class="py-3 w-full font-medium flex place-items-center gap-2 hover:bg-immich-primary/10 text-gray-500 dark:text-gray-300 place-content-center"
|
||||
on:click={() => dispatch('logout')}
|
||||
>
|
||||
<Logout size={24} />
|
||||
Sign Out</button
|
||||
>
|
||||
</div>
|
||||
<div class="mb-4 flex flex-col">
|
||||
<button
|
||||
class="py-3 w-full font-medium flex place-items-center gap-2 hover:bg-immich-primary/10 text-gray-500 dark:text-gray-300 place-content-center"
|
||||
on:click={() => dispatch('logout')}
|
||||
>
|
||||
<Logout size={24} />
|
||||
Sign Out</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,147 +1,141 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { clickOutside } from '$lib/utils/click-outside';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
import TrayArrowUp from 'svelte-material-icons/TrayArrowUp.svelte';
|
||||
import { api, UserResponseDto } from '@api';
|
||||
import ThemeButton from '../theme-button.svelte';
|
||||
import { AppRoute } from '../../../constants';
|
||||
import AccountInfoPanel from './account-info-panel.svelte';
|
||||
import ImmichLogo from '../immich-logo.svelte';
|
||||
import SearchBar from '../search-bar/search-bar.svelte';
|
||||
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
|
||||
import Magnify from 'svelte-material-icons/Magnify.svelte';
|
||||
import IconButton from '$lib/components/elements/buttons/icon-button.svelte';
|
||||
import Cog from 'svelte-material-icons/Cog.svelte';
|
||||
import UserAvatar from '../user-avatar.svelte';
|
||||
export let user: UserResponseDto;
|
||||
export let showUploadButton = true;
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { clickOutside } from '$lib/utils/click-outside';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
import TrayArrowUp from 'svelte-material-icons/TrayArrowUp.svelte';
|
||||
import { api, UserResponseDto } from '@api';
|
||||
import ThemeButton from '../theme-button.svelte';
|
||||
import { AppRoute } from '../../../constants';
|
||||
import AccountInfoPanel from './account-info-panel.svelte';
|
||||
import ImmichLogo from '../immich-logo.svelte';
|
||||
import SearchBar from '../search-bar/search-bar.svelte';
|
||||
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
|
||||
import Magnify from 'svelte-material-icons/Magnify.svelte';
|
||||
import IconButton from '$lib/components/elements/buttons/icon-button.svelte';
|
||||
import Cog from 'svelte-material-icons/Cog.svelte';
|
||||
import UserAvatar from '../user-avatar.svelte';
|
||||
export let user: UserResponseDto;
|
||||
export let showUploadButton = true;
|
||||
|
||||
let shouldShowAccountInfo = false;
|
||||
let shouldShowAccountInfoPanel = false;
|
||||
let shouldShowAccountInfo = false;
|
||||
let shouldShowAccountInfoPanel = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const logOut = async () => {
|
||||
const { data } = await api.authenticationApi.logout();
|
||||
const logOut = async () => {
|
||||
const { data } = await api.authenticationApi.logout();
|
||||
|
||||
await fetch('/auth/logout', { method: 'POST' });
|
||||
await fetch('/auth/logout', { method: 'POST' });
|
||||
|
||||
goto(data.redirectUri || '/auth/login?autoLaunch=0');
|
||||
};
|
||||
goto(data.redirectUri || '/auth/login?autoLaunch=0');
|
||||
};
|
||||
</script>
|
||||
|
||||
<section id="dashboard-navbar" class="fixed h-[var(--navbar-height)] w-screen z-[900] text-sm">
|
||||
<div
|
||||
class="grid h-full md:grid-cols-[theme(spacing.64)_auto] grid-cols-[theme(spacing.18)_auto] border-b dark:border-b-immich-dark-gray items-center py-2 bg-immich-bg dark:bg-immich-dark-bg"
|
||||
>
|
||||
<a
|
||||
data-sveltekit-preload-data="hover"
|
||||
class="flex gap-2 md:mx-6 mx-4 place-items-center"
|
||||
href={AppRoute.PHOTOS}
|
||||
>
|
||||
<ImmichLogo height="35" width="35" />
|
||||
<h1
|
||||
class="font-immich-title text-2xl text-immich-primary dark:text-immich-dark-primary md:block hidden"
|
||||
>
|
||||
IMMICH
|
||||
</h1>
|
||||
</a>
|
||||
<div class="flex justify-between gap-16 pr-6">
|
||||
<div class="w-full max-w-5xl flex-1 pl-4 sm:block hidden">
|
||||
<SearchBar grayTheme={true} />
|
||||
</div>
|
||||
<div
|
||||
class="grid h-full md:grid-cols-[theme(spacing.64)_auto] grid-cols-[theme(spacing.18)_auto] border-b dark:border-b-immich-dark-gray items-center py-2 bg-immich-bg dark:bg-immich-dark-bg"
|
||||
>
|
||||
<a data-sveltekit-preload-data="hover" class="flex gap-2 md:mx-6 mx-4 place-items-center" href={AppRoute.PHOTOS}>
|
||||
<ImmichLogo height="35" width="35" />
|
||||
<h1 class="font-immich-title text-2xl text-immich-primary dark:text-immich-dark-primary md:block hidden">
|
||||
IMMICH
|
||||
</h1>
|
||||
</a>
|
||||
<div class="flex justify-between gap-16 pr-6">
|
||||
<div class="w-full max-w-5xl flex-1 pl-4 sm:block hidden">
|
||||
<SearchBar grayTheme={true} />
|
||||
</div>
|
||||
|
||||
<section class="flex gap-4 place-items-center justify-end max-sm:w-full">
|
||||
<a href={AppRoute.SEARCH} id="search-button" class="sm:hidden pl-4">
|
||||
<IconButton title="Search">
|
||||
<div class="flex gap-2">
|
||||
<Magnify size="1.5em" />
|
||||
</div>
|
||||
</IconButton>
|
||||
</a>
|
||||
<section class="flex gap-4 place-items-center justify-end max-sm:w-full">
|
||||
<a href={AppRoute.SEARCH} id="search-button" class="sm:hidden pl-4">
|
||||
<IconButton title="Search">
|
||||
<div class="flex gap-2">
|
||||
<Magnify size="1.5em" />
|
||||
</div>
|
||||
</IconButton>
|
||||
</a>
|
||||
|
||||
<ThemeButton />
|
||||
<ThemeButton />
|
||||
|
||||
{#if !$page.url.pathname.includes('/admin') && showUploadButton}
|
||||
<div in:fly={{ x: 50, duration: 250 }}>
|
||||
<LinkButton on:click={() => dispatch('uploadClicked')}>
|
||||
<div class="flex gap-2">
|
||||
<TrayArrowUp size="1.5em" />
|
||||
<span class="md:block hidden">Upload</span>
|
||||
</div>
|
||||
</LinkButton>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !$page.url.pathname.includes('/admin') && showUploadButton}
|
||||
<div in:fly={{ x: 50, duration: 250 }}>
|
||||
<LinkButton on:click={() => dispatch('uploadClicked')}>
|
||||
<div class="flex gap-2">
|
||||
<TrayArrowUp size="1.5em" />
|
||||
<span class="md:block hidden">Upload</span>
|
||||
</div>
|
||||
</LinkButton>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if user.isAdmin}
|
||||
<a data-sveltekit-preload-data="hover" href={AppRoute.ADMIN_USER_MANAGEMENT}>
|
||||
<div class="sm:block hidden">
|
||||
<LinkButton>
|
||||
<span
|
||||
class={$page.url.pathname.includes('/admin')
|
||||
? 'text-immich-primary dark:text-immich-dark-primary underline item'
|
||||
: ''}
|
||||
>
|
||||
Administration
|
||||
</span>
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div class="sm:hidden block">
|
||||
<IconButton title="Administration">
|
||||
<Cog
|
||||
size="1.5em"
|
||||
class="dark:text-immich-dark-fg {$page.url.pathname.includes('/admin')
|
||||
? 'text-immich-primary dark:text-immich-dark-primary'
|
||||
: ''}"
|
||||
/>
|
||||
</IconButton>
|
||||
<hr
|
||||
class={$page.url.pathname.includes('/admin')
|
||||
? 'block border-1 w-2/3 mx-auto border-immich-primary dark:border-immich-dark-primary'
|
||||
: 'hidden'}
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
{#if user.isAdmin}
|
||||
<a data-sveltekit-preload-data="hover" href={AppRoute.ADMIN_USER_MANAGEMENT}>
|
||||
<div class="sm:block hidden">
|
||||
<LinkButton>
|
||||
<span
|
||||
class={$page.url.pathname.includes('/admin')
|
||||
? 'text-immich-primary dark:text-immich-dark-primary underline item'
|
||||
: ''}
|
||||
>
|
||||
Administration
|
||||
</span>
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div class="sm:hidden block">
|
||||
<IconButton title="Administration">
|
||||
<Cog
|
||||
size="1.5em"
|
||||
class="dark:text-immich-dark-fg {$page.url.pathname.includes('/admin')
|
||||
? 'text-immich-primary dark:text-immich-dark-primary'
|
||||
: ''}"
|
||||
/>
|
||||
</IconButton>
|
||||
<hr
|
||||
class={$page.url.pathname.includes('/admin')
|
||||
? 'block border-1 w-2/3 mx-auto border-immich-primary dark:border-immich-dark-primary'
|
||||
: 'hidden'}
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<div use:clickOutside on:outclick={() => (shouldShowAccountInfoPanel = false)}>
|
||||
<button
|
||||
class="flex"
|
||||
on:mouseover={() => (shouldShowAccountInfo = true)}
|
||||
on:focus={() => (shouldShowAccountInfo = true)}
|
||||
on:blur={() => (shouldShowAccountInfo = false)}
|
||||
on:mouseleave={() => (shouldShowAccountInfo = false)}
|
||||
on:click={() => (shouldShowAccountInfoPanel = !shouldShowAccountInfoPanel)}
|
||||
>
|
||||
<UserAvatar {user} size="md" showTitle={false} interactive />
|
||||
</button>
|
||||
<div use:clickOutside on:outclick={() => (shouldShowAccountInfoPanel = false)}>
|
||||
<button
|
||||
class="flex"
|
||||
on:mouseover={() => (shouldShowAccountInfo = true)}
|
||||
on:focus={() => (shouldShowAccountInfo = true)}
|
||||
on:blur={() => (shouldShowAccountInfo = false)}
|
||||
on:mouseleave={() => (shouldShowAccountInfo = false)}
|
||||
on:click={() => (shouldShowAccountInfoPanel = !shouldShowAccountInfoPanel)}
|
||||
>
|
||||
<UserAvatar {user} size="md" showTitle={false} interactive />
|
||||
</button>
|
||||
|
||||
{#if shouldShowAccountInfo && !shouldShowAccountInfoPanel}
|
||||
<div
|
||||
in:fade={{ delay: 500, duration: 150 }}
|
||||
out:fade={{ delay: 200, duration: 150 }}
|
||||
class="absolute -bottom-12 right-5 border bg-gray-500 dark:bg-immich-dark-gray text-[12px] text-gray-100 p-2 rounded-md shadow-md dark:border-immich-dark-gray"
|
||||
>
|
||||
<p>{user.firstName} {user.lastName}</p>
|
||||
<p>{user.email}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if shouldShowAccountInfo && !shouldShowAccountInfoPanel}
|
||||
<div
|
||||
in:fade={{ delay: 500, duration: 150 }}
|
||||
out:fade={{ delay: 200, duration: 150 }}
|
||||
class="absolute -bottom-12 right-5 border bg-gray-500 dark:bg-immich-dark-gray text-[12px] text-gray-100 p-2 rounded-md shadow-md dark:border-immich-dark-gray"
|
||||
>
|
||||
<p>{user.firstName} {user.lastName}</p>
|
||||
<p>{user.email}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if shouldShowAccountInfoPanel}
|
||||
<AccountInfoPanel {user} on:logout={logOut} />
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{#if shouldShowAccountInfoPanel}
|
||||
<AccountInfoPanel {user} on:logout={logOut} />
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
/* Used by layouts to ensure proper spacing between navbar and content */
|
||||
--navbar-height: calc(theme(spacing.18) + 4px);
|
||||
}
|
||||
:root {
|
||||
/* Used by layouts to ensure proper spacing between navbar and content */
|
||||
--navbar-height: calc(theme(spacing.18) + 4px);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { tweened } from 'svelte/motion';
|
||||
import { onMount } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { tweened } from 'svelte/motion';
|
||||
|
||||
const progress = tweened(0, {
|
||||
duration: 1000,
|
||||
easing: cubicOut
|
||||
});
|
||||
const progress = tweened(0, {
|
||||
duration: 1000,
|
||||
easing: cubicOut,
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
progress.set(90);
|
||||
});
|
||||
onMount(() => {
|
||||
progress.set(90);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="absolute top-0 left-0 w-screen h-[3px] bg-white z-[999999999]">
|
||||
<span class="absolute bg-immich-primary h-[3px]" style:width={`${$progress}%`} />
|
||||
<span class="absolute bg-immich-primary h-[3px]" style:width={`${$progress}%`} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,41 +1,41 @@
|
|||
import { jest, describe, it } from '@jest/globals';
|
||||
import { render, cleanup, RenderResult } from '@testing-library/svelte';
|
||||
import { describe, it, jest } from '@jest/globals';
|
||||
import '@testing-library/jest-dom';
|
||||
import { cleanup, render, RenderResult } from '@testing-library/svelte';
|
||||
import { NotificationType } from '../notification';
|
||||
import NotificationCard from '../notification-card.svelte';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
describe('NotificationCard component', () => {
|
||||
let sut: RenderResult<NotificationCard>;
|
||||
let sut: RenderResult<NotificationCard>;
|
||||
|
||||
it('disposes timeout if already removed from the DOM', () => {
|
||||
jest.spyOn(window, 'clearTimeout');
|
||||
it('disposes timeout if already removed from the DOM', () => {
|
||||
jest.spyOn(window, 'clearTimeout');
|
||||
|
||||
sut = render(NotificationCard, {
|
||||
notificationInfo: {
|
||||
id: 1234,
|
||||
message: 'Notification message',
|
||||
timeout: 1000,
|
||||
type: NotificationType.Info,
|
||||
action: { type: 'discard' }
|
||||
}
|
||||
});
|
||||
sut = render(NotificationCard, {
|
||||
notificationInfo: {
|
||||
id: 1234,
|
||||
message: 'Notification message',
|
||||
timeout: 1000,
|
||||
type: NotificationType.Info,
|
||||
action: { type: 'discard' },
|
||||
},
|
||||
});
|
||||
|
||||
cleanup();
|
||||
expect(window.clearTimeout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
cleanup();
|
||||
expect(window.clearTimeout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows message and title', () => {
|
||||
sut = render(NotificationCard, {
|
||||
notificationInfo: {
|
||||
id: 1234,
|
||||
message: 'Notification message',
|
||||
timeout: 1000,
|
||||
type: NotificationType.Info,
|
||||
action: { type: 'discard' }
|
||||
}
|
||||
});
|
||||
it('shows message and title', () => {
|
||||
sut = render(NotificationCard, {
|
||||
notificationInfo: {
|
||||
id: 1234,
|
||||
message: 'Notification message',
|
||||
timeout: 1000,
|
||||
type: NotificationType.Info,
|
||||
action: { type: 'discard' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(sut.getByTestId('title')).toHaveTextContent('Info');
|
||||
expect(sut.getByTestId('message')).toHaveTextContent('Notification message');
|
||||
});
|
||||
expect(sut.getByTestId('title')).toHaveTextContent('Info');
|
||||
expect(sut.getByTestId('message')).toHaveTextContent('Notification message');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,44 +1,42 @@
|
|||
import { jest, describe, it } from '@jest/globals';
|
||||
import { render, RenderResult, waitFor } from '@testing-library/svelte';
|
||||
import { notificationController, NotificationType } from '../notification';
|
||||
import { get } from 'svelte/store';
|
||||
import NotificationList from '../notification-list.svelte';
|
||||
import { describe, it, jest } from '@jest/globals';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, RenderResult, waitFor } from '@testing-library/svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { notificationController, NotificationType } from '../notification';
|
||||
import NotificationList from '../notification-list.svelte';
|
||||
|
||||
function _getNotificationListElement(
|
||||
sut: RenderResult<NotificationList>
|
||||
): HTMLAnchorElement | null {
|
||||
return sut.container.querySelector('#notification-list');
|
||||
function _getNotificationListElement(sut: RenderResult<NotificationList>): HTMLAnchorElement | null {
|
||||
return sut.container.querySelector('#notification-list');
|
||||
}
|
||||
|
||||
describe('NotificationList component', () => {
|
||||
const sut: RenderResult<NotificationList> = render(NotificationList);
|
||||
const sut: RenderResult<NotificationList> = render(NotificationList);
|
||||
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows a notification when added and closes it automatically after the delay timeout', async () => {
|
||||
expect(_getNotificationListElement(sut)).not.toBeInTheDocument();
|
||||
it('shows a notification when added and closes it automatically after the delay timeout', async () => {
|
||||
expect(_getNotificationListElement(sut)).not.toBeInTheDocument();
|
||||
|
||||
notificationController.show({
|
||||
message: 'Notification',
|
||||
type: NotificationType.Info,
|
||||
timeout: 3000
|
||||
});
|
||||
notificationController.show({
|
||||
message: 'Notification',
|
||||
type: NotificationType.Info,
|
||||
timeout: 3000,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(_getNotificationListElement(sut)).toBeInTheDocument());
|
||||
await waitFor(() => expect(_getNotificationListElement(sut)).toBeInTheDocument());
|
||||
|
||||
expect(_getNotificationListElement(sut)?.children).toHaveLength(1);
|
||||
expect(_getNotificationListElement(sut)?.children).toHaveLength(1);
|
||||
|
||||
jest.advanceTimersByTime(3000);
|
||||
// due to some weirdness in svelte (or testing-library) need to check if it has been removed from the store to make sure it works.
|
||||
expect(get(notificationController.notificationList)).toHaveLength(0);
|
||||
jest.advanceTimersByTime(3000);
|
||||
// due to some weirdness in svelte (or testing-library) need to check if it has been removed from the store to make sure it works.
|
||||
expect(get(notificationController.notificationList)).toHaveLength(0);
|
||||
|
||||
await waitFor(() => expect(_getNotificationListElement(sut)).not.toBeInTheDocument());
|
||||
});
|
||||
await waitFor(() => expect(_getNotificationListElement(sut)).not.toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,96 +1,95 @@
|
|||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import CloseCircleOutline from 'svelte-material-icons/CloseCircleOutline.svelte';
|
||||
import InformationOutline from 'svelte-material-icons/InformationOutline.svelte';
|
||||
import WindowClose from 'svelte-material-icons/WindowClose.svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import CloseCircleOutline from 'svelte-material-icons/CloseCircleOutline.svelte';
|
||||
import InformationOutline from 'svelte-material-icons/InformationOutline.svelte';
|
||||
import WindowClose from 'svelte-material-icons/WindowClose.svelte';
|
||||
|
||||
import {
|
||||
ImmichNotification,
|
||||
notificationController,
|
||||
NotificationType
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
ImmichNotification,
|
||||
notificationController,
|
||||
NotificationType,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let notificationInfo: ImmichNotification;
|
||||
export let notificationInfo: ImmichNotification;
|
||||
|
||||
let infoPrimaryColor = '#4250AF';
|
||||
let errorPrimaryColor = '#E64132';
|
||||
let infoPrimaryColor = '#4250AF';
|
||||
let errorPrimaryColor = '#E64132';
|
||||
|
||||
$: icon =
|
||||
notificationInfo.type === NotificationType.Error ? CloseCircleOutline : InformationOutline;
|
||||
$: icon = notificationInfo.type === NotificationType.Error ? CloseCircleOutline : InformationOutline;
|
||||
|
||||
$: backgroundColor = () => {
|
||||
if (notificationInfo.type === NotificationType.Info) {
|
||||
return '#E0E2F0';
|
||||
}
|
||||
$: backgroundColor = () => {
|
||||
if (notificationInfo.type === NotificationType.Info) {
|
||||
return '#E0E2F0';
|
||||
}
|
||||
|
||||
if (notificationInfo.type === NotificationType.Error) {
|
||||
return '#FBE8E6';
|
||||
}
|
||||
};
|
||||
if (notificationInfo.type === NotificationType.Error) {
|
||||
return '#FBE8E6';
|
||||
}
|
||||
};
|
||||
|
||||
$: borderStyle = () => {
|
||||
if (notificationInfo.type === NotificationType.Info) {
|
||||
return '1px solid #D8DDFF';
|
||||
}
|
||||
$: borderStyle = () => {
|
||||
if (notificationInfo.type === NotificationType.Info) {
|
||||
return '1px solid #D8DDFF';
|
||||
}
|
||||
|
||||
if (notificationInfo.type === NotificationType.Error) {
|
||||
return '1px solid #F0E8E7';
|
||||
}
|
||||
};
|
||||
if (notificationInfo.type === NotificationType.Error) {
|
||||
return '1px solid #F0E8E7';
|
||||
}
|
||||
};
|
||||
|
||||
$: primaryColor = () => {
|
||||
if (notificationInfo.type === NotificationType.Info) {
|
||||
return infoPrimaryColor;
|
||||
}
|
||||
$: primaryColor = () => {
|
||||
if (notificationInfo.type === NotificationType.Info) {
|
||||
return infoPrimaryColor;
|
||||
}
|
||||
|
||||
if (notificationInfo.type === NotificationType.Error) {
|
||||
return errorPrimaryColor;
|
||||
}
|
||||
};
|
||||
if (notificationInfo.type === NotificationType.Error) {
|
||||
return errorPrimaryColor;
|
||||
}
|
||||
};
|
||||
|
||||
let removeNotificationTimeout: NodeJS.Timeout | undefined = undefined;
|
||||
let removeNotificationTimeout: NodeJS.Timeout | undefined = undefined;
|
||||
|
||||
onMount(() => {
|
||||
removeNotificationTimeout = setTimeout(discard, notificationInfo.timeout);
|
||||
return () => clearTimeout(removeNotificationTimeout);
|
||||
});
|
||||
onMount(() => {
|
||||
removeNotificationTimeout = setTimeout(discard, notificationInfo.timeout);
|
||||
return () => clearTimeout(removeNotificationTimeout);
|
||||
});
|
||||
|
||||
const discard = () => {
|
||||
notificationController.removeNotificationById(notificationInfo.id);
|
||||
};
|
||||
const discard = () => {
|
||||
notificationController.removeNotificationById(notificationInfo.id);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
const action = notificationInfo.action;
|
||||
if (action.type == 'discard') {
|
||||
discard();
|
||||
} else if (action.type == 'link') {
|
||||
window.open(action.target);
|
||||
}
|
||||
};
|
||||
const handleClick = () => {
|
||||
const action = notificationInfo.action;
|
||||
if (action.type == 'discard') {
|
||||
discard();
|
||||
} else if (action.type == 'link') {
|
||||
window.open(action.target);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
transition:fade={{ duration: 250 }}
|
||||
style:background-color={backgroundColor()}
|
||||
style:border={borderStyle()}
|
||||
class="min-h-[80px] w-[300px] rounded-2xl z-[999999] shadow-md p-4 mb-4 hover:cursor-pointer"
|
||||
on:click={handleClick}
|
||||
on:keydown={handleClick}
|
||||
transition:fade={{ duration: 250 }}
|
||||
style:background-color={backgroundColor()}
|
||||
style:border={borderStyle()}
|
||||
class="min-h-[80px] w-[300px] rounded-2xl z-[999999] shadow-md p-4 mb-4 hover:cursor-pointer"
|
||||
on:click={handleClick}
|
||||
on:keydown={handleClick}
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex gap-2 place-items-center">
|
||||
<svelte:component this={icon} color={primaryColor()} size="20" />
|
||||
<h2 style:color={primaryColor()} class="font-medium" data-testid="title">
|
||||
{notificationInfo.type.toString()}
|
||||
</h2>
|
||||
</div>
|
||||
<button on:click|stopPropagation={discard}>
|
||||
<svelte:component this={WindowClose} size="20" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex gap-2 place-items-center">
|
||||
<svelte:component this={icon} color={primaryColor()} size="20" />
|
||||
<h2 style:color={primaryColor()} class="font-medium" data-testid="title">
|
||||
{notificationInfo.type.toString()}
|
||||
</h2>
|
||||
</div>
|
||||
<button on:click|stopPropagation={discard}>
|
||||
<svelte:component this={WindowClose} size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="whitespace-pre-wrap text-sm pl-[28px] pr-[16px]" data-testid="message">
|
||||
{notificationInfo.message}
|
||||
</p>
|
||||
<p class="whitespace-pre-wrap text-sm pl-[28px] pr-[16px]" data-testid="message">
|
||||
{notificationInfo.message}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,24 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { notificationController } from './notification';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { notificationController } from './notification';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import NotificationCard from './notification-card.svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import NotificationCard from './notification-card.svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
|
||||
const { notificationList } = notificationController;
|
||||
const { notificationList } = notificationController;
|
||||
</script>
|
||||
|
||||
{#if $notificationList.length > 0}
|
||||
<section
|
||||
transition:fade={{ duration: 250 }}
|
||||
id="notification-list"
|
||||
class="absolute right-5 top-[80px] z-[99999999]"
|
||||
>
|
||||
{#each $notificationList as notificationInfo (notificationInfo.id)}
|
||||
<div animate:flip={{ duration: 250, easing: quintOut }}>
|
||||
<NotificationCard {notificationInfo} />
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
<section transition:fade={{ duration: 250 }} id="notification-list" class="absolute right-5 top-[80px] z-[99999999]">
|
||||
{#each $notificationList as notificationInfo (notificationInfo.id)}
|
||||
<div animate:flip={{ duration: 250, easing: quintOut }}>
|
||||
<NotificationCard {notificationInfo} />
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
export enum NotificationType {
|
||||
Info = 'Info',
|
||||
Error = 'Error'
|
||||
Info = 'Info',
|
||||
Error = 'Error',
|
||||
}
|
||||
|
||||
export class ImmichNotification {
|
||||
id = new Date().getTime();
|
||||
type!: NotificationType;
|
||||
message!: string;
|
||||
action!: NotificationAction;
|
||||
timeout = 3000;
|
||||
id = new Date().getTime();
|
||||
type!: NotificationType;
|
||||
message!: string;
|
||||
action!: NotificationAction;
|
||||
timeout = 3000;
|
||||
}
|
||||
|
||||
type DiscardAction = { type: 'discard' };
|
||||
|
|
@ -19,50 +19,50 @@ type LinkAction = { type: 'link'; target: string };
|
|||
export type NotificationAction = DiscardAction | NoopAction | LinkAction;
|
||||
|
||||
export class ImmichNotificationDto {
|
||||
/**
|
||||
* Notification type
|
||||
* @type {NotificationType} [Info, Error]
|
||||
*/
|
||||
type: NotificationType = NotificationType.Info;
|
||||
/**
|
||||
* Notification type
|
||||
* @type {NotificationType} [Info, Error]
|
||||
*/
|
||||
type: NotificationType = NotificationType.Info;
|
||||
|
||||
/**
|
||||
* Notification message
|
||||
*/
|
||||
message = '';
|
||||
/**
|
||||
* Notification message
|
||||
*/
|
||||
message = '';
|
||||
|
||||
/**
|
||||
* Timeout in miliseconds
|
||||
*/
|
||||
timeout?: number;
|
||||
/**
|
||||
* Timeout in miliseconds
|
||||
*/
|
||||
timeout?: number;
|
||||
|
||||
/**
|
||||
* The action to take when the notification is clicked
|
||||
*/
|
||||
action?: NotificationAction;
|
||||
/**
|
||||
* The action to take when the notification is clicked
|
||||
*/
|
||||
action?: NotificationAction;
|
||||
}
|
||||
|
||||
function createNotificationList() {
|
||||
const notificationList = writable<ImmichNotification[]>([]);
|
||||
const notificationList = writable<ImmichNotification[]>([]);
|
||||
|
||||
const show = (notificationInfo: ImmichNotificationDto) => {
|
||||
const newNotification = new ImmichNotification();
|
||||
newNotification.message = notificationInfo.message;
|
||||
newNotification.type = notificationInfo.type;
|
||||
newNotification.timeout = notificationInfo.timeout || 3000;
|
||||
newNotification.action = notificationInfo.action || { type: 'discard' };
|
||||
const show = (notificationInfo: ImmichNotificationDto) => {
|
||||
const newNotification = new ImmichNotification();
|
||||
newNotification.message = notificationInfo.message;
|
||||
newNotification.type = notificationInfo.type;
|
||||
newNotification.timeout = notificationInfo.timeout || 3000;
|
||||
newNotification.action = notificationInfo.action || { type: 'discard' };
|
||||
|
||||
notificationList.update((currentList) => [...currentList, newNotification]);
|
||||
};
|
||||
notificationList.update((currentList) => [...currentList, newNotification]);
|
||||
};
|
||||
|
||||
const removeNotificationById = (id: number) => {
|
||||
notificationList.update((currentList) => currentList.filter((n) => n.id != id));
|
||||
};
|
||||
const removeNotificationById = (id: number) => {
|
||||
notificationList.update((currentList) => currentList.filter((n) => n.id != id));
|
||||
};
|
||||
|
||||
return {
|
||||
show,
|
||||
removeNotificationById,
|
||||
notificationList
|
||||
};
|
||||
return {
|
||||
show,
|
||||
removeNotificationById,
|
||||
notificationList,
|
||||
};
|
||||
}
|
||||
|
||||
export const notificationController = createNotificationList();
|
||||
|
|
|
|||
|
|
@ -1,56 +1,56 @@
|
|||
<script context="module" lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
/**
|
||||
* Usage: <div use:portal={'css selector'}> or <div use:portal={document.body}>
|
||||
*/
|
||||
export function portal(el: HTMLElement, target: HTMLElement | string = 'body') {
|
||||
let targetEl;
|
||||
async function update(newTarget: HTMLElement | string) {
|
||||
target = newTarget;
|
||||
if (typeof target === 'string') {
|
||||
targetEl = document.querySelector(target);
|
||||
if (targetEl === null) {
|
||||
await tick();
|
||||
targetEl = document.querySelector(target);
|
||||
}
|
||||
if (targetEl === null) {
|
||||
throw new Error(`No element found matching css selector: "${target}"`);
|
||||
}
|
||||
} else if (target instanceof HTMLElement) {
|
||||
targetEl = target;
|
||||
} else {
|
||||
throw new TypeError(
|
||||
`Unknown portal target type: ${
|
||||
target === null ? 'null' : typeof target
|
||||
}. Allowed types: string (CSS selector) or HTMLElement.`
|
||||
);
|
||||
}
|
||||
targetEl.appendChild(el);
|
||||
el.hidden = false;
|
||||
}
|
||||
/**
|
||||
* Usage: <div use:portal={'css selector'}> or <div use:portal={document.body}>
|
||||
*/
|
||||
export function portal(el: HTMLElement, target: HTMLElement | string = 'body') {
|
||||
let targetEl;
|
||||
async function update(newTarget: HTMLElement | string) {
|
||||
target = newTarget;
|
||||
if (typeof target === 'string') {
|
||||
targetEl = document.querySelector(target);
|
||||
if (targetEl === null) {
|
||||
await tick();
|
||||
targetEl = document.querySelector(target);
|
||||
}
|
||||
if (targetEl === null) {
|
||||
throw new Error(`No element found matching css selector: "${target}"`);
|
||||
}
|
||||
} else if (target instanceof HTMLElement) {
|
||||
targetEl = target;
|
||||
} else {
|
||||
throw new TypeError(
|
||||
`Unknown portal target type: ${
|
||||
target === null ? 'null' : typeof target
|
||||
}. Allowed types: string (CSS selector) or HTMLElement.`,
|
||||
);
|
||||
}
|
||||
targetEl.appendChild(el);
|
||||
el.hidden = false;
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (el.parentNode) {
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
}
|
||||
function destroy() {
|
||||
if (el.parentNode) {
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
update(target);
|
||||
return {
|
||||
update,
|
||||
destroy
|
||||
};
|
||||
}
|
||||
update(target);
|
||||
return {
|
||||
update,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
/**
|
||||
* DOM Element or CSS Selector
|
||||
*/
|
||||
export let target: HTMLElement | string = 'body';
|
||||
/**
|
||||
* DOM Element or CSS Selector
|
||||
*/
|
||||
export let target: HTMLElement | string = 'body';
|
||||
</script>
|
||||
|
||||
<div use:portal={target} hidden>
|
||||
<slot />
|
||||
<slot />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,161 +1,161 @@
|
|||
<script lang="ts" context="module">
|
||||
type OnScrollbarClick = {
|
||||
onscrollbarclick: OnScrollbarClickDetail;
|
||||
};
|
||||
type OnScrollbarClick = {
|
||||
onscrollbarclick: OnScrollbarClickDetail;
|
||||
};
|
||||
|
||||
export type OnScrollbarClickDetail = {
|
||||
scrollTo: number;
|
||||
};
|
||||
export type OnScrollbarClickDetail = {
|
||||
scrollTo: number;
|
||||
};
|
||||
|
||||
type OnScrollbarDrag = {
|
||||
onscrollbardrag: OnScrollbarDragDetail;
|
||||
};
|
||||
type OnScrollbarDrag = {
|
||||
onscrollbardrag: OnScrollbarDragDetail;
|
||||
};
|
||||
|
||||
export type OnScrollbarDragDetail = {
|
||||
scrollTo: number;
|
||||
};
|
||||
export type OnScrollbarDragDetail = {
|
||||
scrollTo: number;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store';
|
||||
import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store';
|
||||
|
||||
import { assetGridState } from '$lib/stores/assets.store';
|
||||
import { assetGridState } from '$lib/stores/assets.store';
|
||||
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { SegmentScrollbarLayout } from './segment-scrollbar-layout';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { SegmentScrollbarLayout } from './segment-scrollbar-layout';
|
||||
|
||||
export let scrollTop = 0;
|
||||
export let scrollbarHeight = 0;
|
||||
export let scrollTop = 0;
|
||||
export let scrollbarHeight = 0;
|
||||
|
||||
$: timelineHeight = $assetGridState.timelineHeight;
|
||||
$: timelineScrolltop = (scrollbarPosition / scrollbarHeight) * timelineHeight;
|
||||
$: timelineHeight = $assetGridState.timelineHeight;
|
||||
$: timelineScrolltop = (scrollbarPosition / scrollbarHeight) * timelineHeight;
|
||||
|
||||
let segmentScrollbarLayout: SegmentScrollbarLayout[] = [];
|
||||
let isHover = false;
|
||||
let isDragging = false;
|
||||
let hoveredDate: Date;
|
||||
let currentMouseYLocation = 0;
|
||||
let scrollbarPosition = 0;
|
||||
let animationTick = false;
|
||||
let segmentScrollbarLayout: SegmentScrollbarLayout[] = [];
|
||||
let isHover = false;
|
||||
let isDragging = false;
|
||||
let hoveredDate: Date;
|
||||
let currentMouseYLocation = 0;
|
||||
let scrollbarPosition = 0;
|
||||
let animationTick = false;
|
||||
|
||||
const { isAlbumAssetSelectionOpen } = albumAssetSelectionStore;
|
||||
$: offset = $isAlbumAssetSelectionOpen ? 100 : 71;
|
||||
const dispatchClick = createEventDispatcher<OnScrollbarClick>();
|
||||
const dispatchDrag = createEventDispatcher<OnScrollbarDrag>();
|
||||
$: {
|
||||
scrollbarPosition = (scrollTop / timelineHeight) * scrollbarHeight;
|
||||
}
|
||||
const { isAlbumAssetSelectionOpen } = albumAssetSelectionStore;
|
||||
$: offset = $isAlbumAssetSelectionOpen ? 100 : 71;
|
||||
const dispatchClick = createEventDispatcher<OnScrollbarClick>();
|
||||
const dispatchDrag = createEventDispatcher<OnScrollbarDrag>();
|
||||
$: {
|
||||
scrollbarPosition = (scrollTop / timelineHeight) * scrollbarHeight;
|
||||
}
|
||||
|
||||
$: {
|
||||
let result: SegmentScrollbarLayout[] = [];
|
||||
for (const bucket of $assetGridState.buckets) {
|
||||
let segmentLayout = new SegmentScrollbarLayout();
|
||||
segmentLayout.count = bucket.assets.length;
|
||||
segmentLayout.height = (bucket.bucketHeight / timelineHeight) * scrollbarHeight;
|
||||
segmentLayout.timeGroup = bucket.bucketDate;
|
||||
result.push(segmentLayout);
|
||||
}
|
||||
segmentScrollbarLayout = result;
|
||||
}
|
||||
$: {
|
||||
let result: SegmentScrollbarLayout[] = [];
|
||||
for (const bucket of $assetGridState.buckets) {
|
||||
let segmentLayout = new SegmentScrollbarLayout();
|
||||
segmentLayout.count = bucket.assets.length;
|
||||
segmentLayout.height = (bucket.bucketHeight / timelineHeight) * scrollbarHeight;
|
||||
segmentLayout.timeGroup = bucket.bucketDate;
|
||||
result.push(segmentLayout);
|
||||
}
|
||||
segmentScrollbarLayout = result;
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent, currentDate: Date) => {
|
||||
currentMouseYLocation = e.clientY - offset - 30;
|
||||
const handleMouseMove = (e: MouseEvent, currentDate: Date) => {
|
||||
currentMouseYLocation = e.clientY - offset - 30;
|
||||
|
||||
hoveredDate = new Date(currentDate.toISOString().slice(0, -1));
|
||||
};
|
||||
hoveredDate = new Date(currentDate.toISOString().slice(0, -1));
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
isDragging = true;
|
||||
scrollbarPosition = e.clientY - offset;
|
||||
};
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
isDragging = true;
|
||||
scrollbarPosition = e.clientY - offset;
|
||||
};
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
isDragging = false;
|
||||
scrollbarPosition = e.clientY - offset;
|
||||
dispatchClick('onscrollbarclick', { scrollTo: timelineScrolltop });
|
||||
};
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
isDragging = false;
|
||||
scrollbarPosition = e.clientY - offset;
|
||||
dispatchClick('onscrollbarclick', { scrollTo: timelineScrolltop });
|
||||
};
|
||||
|
||||
const handleMouseDrag = (e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
if (!animationTick) {
|
||||
window.requestAnimationFrame(() => {
|
||||
const dy = e.clientY - scrollbarPosition - offset;
|
||||
scrollbarPosition += dy;
|
||||
dispatchDrag('onscrollbardrag', { scrollTo: timelineScrolltop });
|
||||
animationTick = false;
|
||||
});
|
||||
const handleMouseDrag = (e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
if (!animationTick) {
|
||||
window.requestAnimationFrame(() => {
|
||||
const dy = e.clientY - scrollbarPosition - offset;
|
||||
scrollbarPosition += dy;
|
||||
dispatchDrag('onscrollbardrag', { scrollTo: timelineScrolltop });
|
||||
animationTick = false;
|
||||
});
|
||||
|
||||
animationTick = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
animationTick = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
id="immich-scrubbable-scrollbar"
|
||||
class="fixed right-0 bg-immich-bg z-[100] hover:cursor-row-resize select-none"
|
||||
style:width={isDragging ? '100vw' : '60px'}
|
||||
style:background-color={isDragging ? 'transparent' : 'transparent'}
|
||||
on:mouseenter={() => (isHover = true)}
|
||||
on:mouseleave={() => {
|
||||
isHover = false;
|
||||
isDragging = false;
|
||||
}}
|
||||
on:mouseup={handleMouseUp}
|
||||
on:mousemove={handleMouseDrag}
|
||||
on:mousedown={handleMouseDown}
|
||||
style:height={scrollbarHeight + 'px'}
|
||||
id="immich-scrubbable-scrollbar"
|
||||
class="fixed right-0 bg-immich-bg z-[100] hover:cursor-row-resize select-none"
|
||||
style:width={isDragging ? '100vw' : '60px'}
|
||||
style:background-color={isDragging ? 'transparent' : 'transparent'}
|
||||
on:mouseenter={() => (isHover = true)}
|
||||
on:mouseleave={() => {
|
||||
isHover = false;
|
||||
isDragging = false;
|
||||
}}
|
||||
on:mouseup={handleMouseUp}
|
||||
on:mousemove={handleMouseDrag}
|
||||
on:mousedown={handleMouseDown}
|
||||
style:height={scrollbarHeight + 'px'}
|
||||
>
|
||||
{#if isHover}
|
||||
<div
|
||||
class="border-b-2 border-immich-primary dark:border-immich-dark-primary w-[100px] right-0 pr-6 py-1 text-sm pl-1 font-medium absolute bg-immich-bg dark:bg-immich-dark-gray z-[100] pointer-events-none rounded-tl-md shadow-lg dark:text-immich-dark-fg"
|
||||
style:top={currentMouseYLocation + 'px'}
|
||||
>
|
||||
{hoveredDate?.toLocaleString('default', { month: 'short' })}
|
||||
{hoveredDate?.getFullYear()}
|
||||
</div>
|
||||
{/if}
|
||||
{#if isHover}
|
||||
<div
|
||||
class="border-b-2 border-immich-primary dark:border-immich-dark-primary w-[100px] right-0 pr-6 py-1 text-sm pl-1 font-medium absolute bg-immich-bg dark:bg-immich-dark-gray z-[100] pointer-events-none rounded-tl-md shadow-lg dark:text-immich-dark-fg"
|
||||
style:top={currentMouseYLocation + 'px'}
|
||||
>
|
||||
{hoveredDate?.toLocaleString('default', { month: 'short' })}
|
||||
{hoveredDate?.getFullYear()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Scroll Position Indicator Line -->
|
||||
{#if !isDragging}
|
||||
<div
|
||||
class="absolute right-0 w-10 h-[2px] bg-immich-primary dark:bg-immich-dark-primary"
|
||||
style:top={scrollbarPosition + 'px'}
|
||||
/>
|
||||
{/if}
|
||||
<!-- Time Segment -->
|
||||
{#each segmentScrollbarLayout as segment, index (segment.timeGroup)}
|
||||
{@const groupDate = new Date(segment.timeGroup)}
|
||||
<!-- Scroll Position Indicator Line -->
|
||||
{#if !isDragging}
|
||||
<div
|
||||
class="absolute right-0 w-10 h-[2px] bg-immich-primary dark:bg-immich-dark-primary"
|
||||
style:top={scrollbarPosition + 'px'}
|
||||
/>
|
||||
{/if}
|
||||
<!-- Time Segment -->
|
||||
{#each segmentScrollbarLayout as segment, index (segment.timeGroup)}
|
||||
{@const groupDate = new Date(segment.timeGroup)}
|
||||
|
||||
<div
|
||||
id="time-segment"
|
||||
class="relative"
|
||||
style:height={segment.height + 'px'}
|
||||
aria-label={segment.timeGroup + ' ' + segment.count}
|
||||
on:mousemove={(e) => handleMouseMove(e, groupDate)}
|
||||
>
|
||||
{#if new Date(segmentScrollbarLayout[index - 1]?.timeGroup).getFullYear() !== groupDate.getFullYear()}
|
||||
{#if segment.height > 8}
|
||||
<div
|
||||
aria-label={segment.timeGroup + ' ' + segment.count}
|
||||
class="absolute right-0 pr-5 z-10 text-xs font-medium dark:text-immich-dark-fg"
|
||||
>
|
||||
{groupDate.getFullYear()}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if segment.height > 5}
|
||||
<div
|
||||
aria-label={segment.timeGroup + ' ' + segment.count}
|
||||
class="absolute right-0 rounded-full h-[4px] w-[4px] mr-3 bg-gray-300 block"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<div
|
||||
id="time-segment"
|
||||
class="relative"
|
||||
style:height={segment.height + 'px'}
|
||||
aria-label={segment.timeGroup + ' ' + segment.count}
|
||||
on:mousemove={(e) => handleMouseMove(e, groupDate)}
|
||||
>
|
||||
{#if new Date(segmentScrollbarLayout[index - 1]?.timeGroup).getFullYear() !== groupDate.getFullYear()}
|
||||
{#if segment.height > 8}
|
||||
<div
|
||||
aria-label={segment.timeGroup + ' ' + segment.count}
|
||||
class="absolute right-0 pr-5 z-10 text-xs font-medium dark:text-immich-dark-fg"
|
||||
>
|
||||
{groupDate.getFullYear()}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if segment.height > 5}
|
||||
<div
|
||||
aria-label={segment.timeGroup + ' ' + segment.count}
|
||||
class="absolute right-0 rounded-full h-[4px] w-[4px] mr-3 bg-gray-300 block"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#immich-scrubbable-scrollbar,
|
||||
#time-segment {
|
||||
contain: layout;
|
||||
}
|
||||
#immich-scrubbable-scrollbar,
|
||||
#time-segment {
|
||||
contain: layout;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export class SegmentScrollbarLayout {
|
||||
height!: number;
|
||||
timeGroup!: string;
|
||||
count!: number;
|
||||
height!: number;
|
||||
timeGroup!: string;
|
||||
count!: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,129 +1,129 @@
|
|||
<script lang="ts">
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import Magnify from 'svelte-material-icons/Magnify.svelte';
|
||||
import Close from 'svelte-material-icons/Close.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { savedSearchTerms } from '$lib/stores/search.store';
|
||||
import { fly } from 'svelte/transition';
|
||||
export let value = '';
|
||||
export let grayTheme: boolean;
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import Magnify from 'svelte-material-icons/Magnify.svelte';
|
||||
import Close from 'svelte-material-icons/Close.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { savedSearchTerms } from '$lib/stores/search.store';
|
||||
import { fly } from 'svelte/transition';
|
||||
export let value = '';
|
||||
export let grayTheme: boolean;
|
||||
|
||||
let showBigSearchBar = false;
|
||||
$: showClearIcon = value.length > 0;
|
||||
let showBigSearchBar = false;
|
||||
$: showClearIcon = value.length > 0;
|
||||
|
||||
function onSearch(saveSearch: boolean) {
|
||||
let clipSearch = 'true';
|
||||
let searchValue = value;
|
||||
function onSearch(saveSearch: boolean) {
|
||||
let clipSearch = 'true';
|
||||
let searchValue = value;
|
||||
|
||||
if (value.slice(0, 2) == 'm:') {
|
||||
clipSearch = 'false';
|
||||
searchValue = value.slice(2);
|
||||
}
|
||||
if (value.slice(0, 2) == 'm:') {
|
||||
clipSearch = 'false';
|
||||
searchValue = value.slice(2);
|
||||
}
|
||||
|
||||
if (saveSearch) {
|
||||
saveSearchTerm(value);
|
||||
}
|
||||
if (saveSearch) {
|
||||
saveSearchTerm(value);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
q: searchValue,
|
||||
clip: clipSearch
|
||||
});
|
||||
const params = new URLSearchParams({
|
||||
q: searchValue,
|
||||
clip: clipSearch,
|
||||
});
|
||||
|
||||
goto(`${AppRoute.SEARCH}?${params}`);
|
||||
}
|
||||
goto(`${AppRoute.SEARCH}?${params}`);
|
||||
}
|
||||
|
||||
const saveSearchTerm = (saveValue: string) => {
|
||||
$savedSearchTerms = [saveValue, ...$savedSearchTerms];
|
||||
const saveSearchTerm = (saveValue: string) => {
|
||||
$savedSearchTerms = [saveValue, ...$savedSearchTerms];
|
||||
|
||||
if ($savedSearchTerms.length > 5) {
|
||||
$savedSearchTerms = $savedSearchTerms.slice(0, 5);
|
||||
}
|
||||
};
|
||||
if ($savedSearchTerms.length > 5) {
|
||||
$savedSearchTerms = $savedSearchTerms.slice(0, 5);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSearchTerm = () => {
|
||||
$savedSearchTerms = [];
|
||||
};
|
||||
const clearSearchTerm = () => {
|
||||
$savedSearchTerms = [];
|
||||
};
|
||||
</script>
|
||||
|
||||
<form
|
||||
draggable="false"
|
||||
autocomplete="off"
|
||||
class="relative text-sm"
|
||||
action={AppRoute.SEARCH}
|
||||
on:reset={() => (value = '')}
|
||||
on:submit|preventDefault={() => onSearch(true)}
|
||||
on:focusin={() => (showBigSearchBar = true)}
|
||||
on:focusout={() => (showBigSearchBar = false)}
|
||||
draggable="false"
|
||||
autocomplete="off"
|
||||
class="relative text-sm"
|
||||
action={AppRoute.SEARCH}
|
||||
on:reset={() => (value = '')}
|
||||
on:submit|preventDefault={() => onSearch(true)}
|
||||
on:focusin={() => (showBigSearchBar = true)}
|
||||
on:focusout={() => (showBigSearchBar = false)}
|
||||
>
|
||||
<label>
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-6">
|
||||
<div class="pointer-events-none dark:text-immich-dark-fg/75">
|
||||
<Magnify size="1.5em" />
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
class="w-full transition-all {grayTheme
|
||||
? 'dark:bg-immich-dark-gray'
|
||||
: 'dark:bg-immich-dark-bg'} text-immich-fg/75 dark:text-immich-dark-fg px-14 py-4 {showBigSearchBar
|
||||
? 'rounded-t-3xl bg-white border border-gray-200 dark:border-gray-800'
|
||||
: 'rounded-3xl bg-gray-200 border border-transparent'}"
|
||||
placeholder="Search your photos"
|
||||
required
|
||||
pattern="^(?!m:$).*$"
|
||||
bind:value
|
||||
/>
|
||||
</label>
|
||||
{#if showClearIcon}
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<button
|
||||
type="reset"
|
||||
class="dark:text-immich-dark-fg/75 hover:bg-immich-primary/5 dark:hover:bg-immich-dark-primary/25 rounded-full p-2 active:bg-immich-primary/10 dark:active:bg-immich-dark-primary/[.35]"
|
||||
>
|
||||
<Close size="1.5em" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<label>
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-6">
|
||||
<div class="pointer-events-none dark:text-immich-dark-fg/75">
|
||||
<Magnify size="1.5em" />
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
class="w-full transition-all {grayTheme
|
||||
? 'dark:bg-immich-dark-gray'
|
||||
: 'dark:bg-immich-dark-bg'} text-immich-fg/75 dark:text-immich-dark-fg px-14 py-4 {showBigSearchBar
|
||||
? 'rounded-t-3xl bg-white border border-gray-200 dark:border-gray-800'
|
||||
: 'rounded-3xl bg-gray-200 border border-transparent'}"
|
||||
placeholder="Search your photos"
|
||||
required
|
||||
pattern="^(?!m:$).*$"
|
||||
bind:value
|
||||
/>
|
||||
</label>
|
||||
{#if showClearIcon}
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<button
|
||||
type="reset"
|
||||
class="dark:text-immich-dark-fg/75 hover:bg-immich-primary/5 dark:hover:bg-immich-dark-primary/25 rounded-full p-2 active:bg-immich-primary/10 dark:active:bg-immich-dark-primary/[.35]"
|
||||
>
|
||||
<Close size="1.5em" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showBigSearchBar}
|
||||
<div
|
||||
transition:fly={{ y: 25, duration: 250 }}
|
||||
class="w-full pb-5 absolute bg-white transition-all rounded-b-3xl shadow-2xl border border-gray-200 dark:bg-immich-dark-gray dark:border-gray-800 dark:text-gray-300"
|
||||
>
|
||||
<div class="px-5 pt-5 text-xs">
|
||||
<p>
|
||||
Smart search is enabled by default, to search for metadata use the syntax <span
|
||||
class="font-mono p-2 font-semibold text-immich-primary dark:text-immich-dark-primary bg-gray-100 rounded-lg dark:bg-gray-900 leading-7"
|
||||
>m:your-search-term</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
{#if showBigSearchBar}
|
||||
<div
|
||||
transition:fly={{ y: 25, duration: 250 }}
|
||||
class="w-full pb-5 absolute bg-white transition-all rounded-b-3xl shadow-2xl border border-gray-200 dark:bg-immich-dark-gray dark:border-gray-800 dark:text-gray-300"
|
||||
>
|
||||
<div class="px-5 pt-5 text-xs">
|
||||
<p>
|
||||
Smart search is enabled by default, to search for metadata use the syntax <span
|
||||
class="font-mono p-2 font-semibold text-immich-primary dark:text-immich-dark-primary bg-gray-100 rounded-lg dark:bg-gray-900 leading-7"
|
||||
>m:your-search-term</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if $savedSearchTerms.length > 0}
|
||||
<div class="px-5 pt-5 text-xs flex justify-between">
|
||||
<p>RECENT SEARCHES</p>
|
||||
<button
|
||||
type="button"
|
||||
class="text-immich-primary dark:text-immich-dark-primary font-semibold p-2 hover:bg-immich-primary/25 rounded-lg"
|
||||
on:click={clearSearchTerm}>Clear all</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $savedSearchTerms.length > 0}
|
||||
<div class="px-5 pt-5 text-xs flex justify-between">
|
||||
<p>RECENT SEARCHES</p>
|
||||
<button
|
||||
type="button"
|
||||
class="text-immich-primary dark:text-immich-dark-primary font-semibold p-2 hover:bg-immich-primary/25 rounded-lg"
|
||||
on:click={clearSearchTerm}>Clear all</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each $savedSearchTerms as savedSearchTerm, i (i)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full hover:bg-gray-100 dark:hover:bg-gray-500/10 px-5 py-3 cursor-pointer flex gap-3 text-black dark:text-gray-300"
|
||||
on:click={() => {
|
||||
value = savedSearchTerm;
|
||||
onSearch(false);
|
||||
}}
|
||||
>
|
||||
<Magnify size="1.5em" />
|
||||
{savedSearchTerm}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#each $savedSearchTerms as savedSearchTerm, i (i)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full hover:bg-gray-100 dark:hover:bg-gray-500/10 px-5 py-3 cursor-pointer flex gap-3 text-black dark:text-gray-300"
|
||||
on:click={() => {
|
||||
value = savedSearchTerm;
|
||||
onSearch(false);
|
||||
}}
|
||||
>
|
||||
<Magnify size="1.5em" />
|
||||
{savedSearchTerm}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,64 +1,60 @@
|
|||
<script lang="ts">
|
||||
import type Icon from 'svelte-material-icons/AbTesting.svelte';
|
||||
import InformationOutline from 'svelte-material-icons/InformationOutline.svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type Icon from 'svelte-material-icons/AbTesting.svelte';
|
||||
import InformationOutline from 'svelte-material-icons/InformationOutline.svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let title: string;
|
||||
export let logo: typeof Icon;
|
||||
export let isSelected: boolean;
|
||||
export let flippedLogo = false;
|
||||
export let title: string;
|
||||
export let logo: typeof Icon;
|
||||
export let isSelected: boolean;
|
||||
export let flippedLogo = false;
|
||||
|
||||
let showMoreInformation = false;
|
||||
let showMoreInformation = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const onButtonClicked = () => dispatch('selected');
|
||||
const dispatch = createEventDispatcher();
|
||||
const onButtonClicked = () => dispatch('selected');
|
||||
</script>
|
||||
|
||||
<div
|
||||
on:click={onButtonClicked}
|
||||
on:keydown={onButtonClicked}
|
||||
class="flex gap-4 justify-between place-items-center w-full transition-[padding] delay-100 duration-100 py-3 rounded-r-full hover:bg-immich-gray dark:hover:bg-immich-dark-gray hover:text-immich-primary dark:text-immich-dark-fg dark:hover:text-immich-dark-primary hover:cursor-pointer
|
||||
on:click={onButtonClicked}
|
||||
on:keydown={onButtonClicked}
|
||||
class="flex gap-4 justify-between place-items-center w-full transition-[padding] delay-100 duration-100 py-3 rounded-r-full hover:bg-immich-gray dark:hover:bg-immich-dark-gray hover:text-immich-primary dark:text-immich-dark-fg dark:hover:text-immich-dark-primary hover:cursor-pointer
|
||||
{isSelected
|
||||
? 'bg-immich-primary/10 dark:bg-immich-dark-primary/10 text-immich-primary dark:text-immich-dark-primary hover:bg-immich-primary/25'
|
||||
: ''}
|
||||
? 'bg-immich-primary/10 dark:bg-immich-dark-primary/10 text-immich-primary dark:text-immich-dark-primary hover:bg-immich-primary/25'
|
||||
: ''}
|
||||
pl-5 group-hover:sm:px-5 md:px-5
|
||||
"
|
||||
>
|
||||
<div class="flex gap-4 place-items-center w-full overflow-hidden truncate">
|
||||
<svelte:component
|
||||
this={logo}
|
||||
size="1.5em"
|
||||
class="shrink-0 {flippedLogo ? '-scale-x-100' : ''}"
|
||||
/>
|
||||
<p class="font-medium text-sm">{title}</p>
|
||||
</div>
|
||||
<div class="flex gap-4 place-items-center w-full overflow-hidden truncate">
|
||||
<svelte:component this={logo} size="1.5em" class="shrink-0 {flippedLogo ? '-scale-x-100' : ''}" />
|
||||
<p class="font-medium text-sm">{title}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="transition-[height] group-hover:sm:overflow-visible md:overflow-visible overflow-hidden duration-100 delay-1000 sm:group-hover:h-auto md:h-auto h-0"
|
||||
>
|
||||
{#if $$slots.moreInformation}
|
||||
<div
|
||||
class="relative flex justify-center select-none cursor-default"
|
||||
on:mouseenter={() => (showMoreInformation = true)}
|
||||
on:mouseleave={() => (showMoreInformation = false)}
|
||||
>
|
||||
<div class="hover:cursor-help p-1 text-gray-600 dark:text-gray-400">
|
||||
<InformationOutline />
|
||||
</div>
|
||||
<div
|
||||
class="transition-[height] group-hover:sm:overflow-visible md:overflow-visible overflow-hidden duration-100 delay-1000 sm:group-hover:h-auto md:h-auto h-0"
|
||||
>
|
||||
{#if $$slots.moreInformation}
|
||||
<div
|
||||
class="relative flex justify-center select-none cursor-default"
|
||||
on:mouseenter={() => (showMoreInformation = true)}
|
||||
on:mouseleave={() => (showMoreInformation = false)}
|
||||
>
|
||||
<div class="hover:cursor-help p-1 text-gray-600 dark:text-gray-400">
|
||||
<InformationOutline />
|
||||
</div>
|
||||
|
||||
{#if showMoreInformation}
|
||||
<div class="absolute right-6 top-0">
|
||||
<div
|
||||
class="flex place-items-center place-content-center whitespace-nowrap rounded-3xl shadow-lg py-3 px-6 bg-immich-bg text-immich-fg dark:bg-gray-600 dark:text-immich-dark-fg text-xs border dark:border-immich-dark-gray"
|
||||
class:hidden={!showMoreInformation}
|
||||
transition:fade={{ duration: 200 }}
|
||||
>
|
||||
<slot name="moreInformation" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if showMoreInformation}
|
||||
<div class="absolute right-6 top-0">
|
||||
<div
|
||||
class="flex place-items-center place-content-center whitespace-nowrap rounded-3xl shadow-lg py-3 px-6 bg-immich-bg text-immich-fg dark:bg-gray-600 dark:text-immich-dark-fg text-xs border dark:border-immich-dark-gray"
|
||||
class:hidden={!showMoreInformation}
|
||||
transition:fade={{ duration: 200 }}
|
||||
>
|
||||
<slot name="moreInformation" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
</script>
|
||||
|
||||
<section
|
||||
id="sidebar"
|
||||
class="group flex flex-col gap-1 pt-8 bg-immich-bg dark:bg-immich-dark-bg transition-all duration-200 z-10 w-18 md:w-64 hover:sm:pr-6 md:pr-6 hover:sm:w-64 hover:sm:shadow-2xl hover:md:shadow-none hover:md:border-none hover:sm:border-r hover:sm:dark:border-r-immich-dark-gray relative overflow-y-auto immich-scrollbar"
|
||||
id="sidebar"
|
||||
class="group flex flex-col gap-1 pt-8 bg-immich-bg dark:bg-immich-dark-bg transition-all duration-200 z-10 w-18 md:w-64 hover:sm:pr-6 md:pr-6 hover:sm:w-64 hover:sm:shadow-2xl hover:md:shadow-none hover:md:border-none hover:sm:border-r hover:sm:dark:border-r-immich-dark-gray relative overflow-y-auto immich-scrollbar"
|
||||
>
|
||||
<slot />
|
||||
<slot />
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,197 +1,174 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '@api';
|
||||
import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
|
||||
import AccountMultiple from 'svelte-material-icons/AccountMultiple.svelte';
|
||||
import ImageAlbum from 'svelte-material-icons/ImageAlbum.svelte';
|
||||
import ImageMultipleOutline from 'svelte-material-icons/ImageMultipleOutline.svelte';
|
||||
import ImageMultiple from 'svelte-material-icons/ImageMultiple.svelte';
|
||||
import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte';
|
||||
import Magnify from 'svelte-material-icons/Magnify.svelte';
|
||||
import Map from 'svelte-material-icons/Map.svelte';
|
||||
import HeartMultipleOutline from 'svelte-material-icons/HeartMultipleOutline.svelte';
|
||||
import HeartMultiple from 'svelte-material-icons/HeartMultiple.svelte';
|
||||
import { AppRoute } from '../../../constants';
|
||||
import LoadingSpinner from '../loading-spinner.svelte';
|
||||
import StatusBox from '../status-box.svelte';
|
||||
import SideBarButton from './side-bar-button.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import SideBarSection from './side-bar-section.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '@api';
|
||||
import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
|
||||
import AccountMultiple from 'svelte-material-icons/AccountMultiple.svelte';
|
||||
import ImageAlbum from 'svelte-material-icons/ImageAlbum.svelte';
|
||||
import ImageMultipleOutline from 'svelte-material-icons/ImageMultipleOutline.svelte';
|
||||
import ImageMultiple from 'svelte-material-icons/ImageMultiple.svelte';
|
||||
import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte';
|
||||
import Magnify from 'svelte-material-icons/Magnify.svelte';
|
||||
import Map from 'svelte-material-icons/Map.svelte';
|
||||
import HeartMultipleOutline from 'svelte-material-icons/HeartMultipleOutline.svelte';
|
||||
import HeartMultiple from 'svelte-material-icons/HeartMultiple.svelte';
|
||||
import { AppRoute } from '../../../constants';
|
||||
import LoadingSpinner from '../loading-spinner.svelte';
|
||||
import StatusBox from '../status-box.svelte';
|
||||
import SideBarButton from './side-bar-button.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import SideBarSection from './side-bar-section.svelte';
|
||||
|
||||
const getAssetCount = async () => {
|
||||
const { data: allAssetCount } = await api.assetApi.getAssetCountByUserId();
|
||||
const { data: archivedCount } = await api.assetApi.getArchivedAssetCountByUserId();
|
||||
const getAssetCount = async () => {
|
||||
const { data: allAssetCount } = await api.assetApi.getAssetCountByUserId();
|
||||
const { data: archivedCount } = await api.assetApi.getArchivedAssetCountByUserId();
|
||||
|
||||
return {
|
||||
videos: allAssetCount.videos - archivedCount.videos,
|
||||
photos: allAssetCount.photos - archivedCount.photos
|
||||
};
|
||||
};
|
||||
return {
|
||||
videos: allAssetCount.videos - archivedCount.videos,
|
||||
photos: allAssetCount.photos - archivedCount.photos,
|
||||
};
|
||||
};
|
||||
|
||||
const getFavoriteCount = async () => {
|
||||
try {
|
||||
const { data: assets } = await api.assetApi.getAllAssets({
|
||||
isFavorite: true,
|
||||
withoutThumbs: true
|
||||
});
|
||||
const getFavoriteCount = async () => {
|
||||
try {
|
||||
const { data: assets } = await api.assetApi.getAllAssets({
|
||||
isFavorite: true,
|
||||
withoutThumbs: true,
|
||||
});
|
||||
|
||||
return {
|
||||
favorites: assets.length
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
favorites: 0
|
||||
};
|
||||
}
|
||||
};
|
||||
return {
|
||||
favorites: assets.length,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
favorites: 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getAlbumCount = async () => {
|
||||
try {
|
||||
const { data: albumCount } = await api.albumApi.getAlbumCount();
|
||||
return albumCount;
|
||||
} catch {
|
||||
return { owned: 0, shared: 0, notShared: 0 };
|
||||
}
|
||||
};
|
||||
const getAlbumCount = async () => {
|
||||
try {
|
||||
const { data: albumCount } = await api.albumApi.getAlbumCount();
|
||||
return albumCount;
|
||||
} catch {
|
||||
return { owned: 0, shared: 0, notShared: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
const getArchivedAssetsCount = async () => {
|
||||
try {
|
||||
const { data: assetCount } = await api.assetApi.getArchivedAssetCountByUserId();
|
||||
const getArchivedAssetsCount = async () => {
|
||||
try {
|
||||
const { data: assetCount } = await api.assetApi.getArchivedAssetCountByUserId();
|
||||
|
||||
return {
|
||||
videos: assetCount.videos,
|
||||
photos: assetCount.photos
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
videos: 0,
|
||||
photos: 0
|
||||
};
|
||||
}
|
||||
};
|
||||
return {
|
||||
videos: assetCount.videos,
|
||||
photos: assetCount.photos,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
videos: 0,
|
||||
photos: 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const isFavoritesSelected = $page.route.id === '/(user)/favorites';
|
||||
const isPhotosSelected = $page.route.id === '/(user)/photos';
|
||||
const isSharingSelected = $page.route.id === '/(user)/sharing';
|
||||
const isFavoritesSelected = $page.route.id === '/(user)/favorites';
|
||||
const isPhotosSelected = $page.route.id === '/(user)/photos';
|
||||
const isSharingSelected = $page.route.id === '/(user)/sharing';
|
||||
</script>
|
||||
|
||||
<SideBarSection>
|
||||
<a
|
||||
data-sveltekit-preload-data="hover"
|
||||
data-sveltekit-noscroll
|
||||
href={AppRoute.PHOTOS}
|
||||
draggable="false"
|
||||
>
|
||||
<SideBarButton
|
||||
title="Photos"
|
||||
logo={isPhotosSelected ? ImageMultiple : ImageMultipleOutline}
|
||||
isSelected={isPhotosSelected}
|
||||
>
|
||||
<svelte:fragment slot="moreInformation">
|
||||
{#await getAssetCount()}
|
||||
<LoadingSpinner />
|
||||
{:then data}
|
||||
<div>
|
||||
<p>{data.videos.toLocaleString($locale)} Videos</p>
|
||||
<p>{data.photos.toLocaleString($locale)} Photos</p>
|
||||
</div>
|
||||
{/await}
|
||||
</svelte:fragment>
|
||||
</SideBarButton>
|
||||
</a>
|
||||
<a
|
||||
data-sveltekit-preload-data="hover"
|
||||
data-sveltekit-noscroll
|
||||
href={AppRoute.EXPLORE}
|
||||
draggable="false"
|
||||
>
|
||||
<SideBarButton
|
||||
title="Explore"
|
||||
logo={Magnify}
|
||||
isSelected={$page.route.id === '/(user)/explore'}
|
||||
/>
|
||||
</a>
|
||||
<a data-sveltekit-preload-data="hover" href={AppRoute.MAP} draggable="false">
|
||||
<SideBarButton title="Map" logo={Map} isSelected={$page.route.id === '/(user)/map'} />
|
||||
</a>
|
||||
<a data-sveltekit-preload-data="hover" href={AppRoute.SHARING} draggable="false">
|
||||
<SideBarButton
|
||||
title="Sharing"
|
||||
logo={isSharingSelected ? AccountMultiple : AccountMultipleOutline}
|
||||
isSelected={isSharingSelected}
|
||||
>
|
||||
<svelte:fragment slot="moreInformation">
|
||||
{#await getAlbumCount()}
|
||||
<LoadingSpinner />
|
||||
{:then data}
|
||||
<div>
|
||||
<p>{data.shared.toLocaleString($locale)} Albums</p>
|
||||
</div>
|
||||
{/await}
|
||||
</svelte:fragment>
|
||||
</SideBarButton>
|
||||
</a>
|
||||
<a data-sveltekit-preload-data="hover" data-sveltekit-noscroll href={AppRoute.PHOTOS} draggable="false">
|
||||
<SideBarButton
|
||||
title="Photos"
|
||||
logo={isPhotosSelected ? ImageMultiple : ImageMultipleOutline}
|
||||
isSelected={isPhotosSelected}
|
||||
>
|
||||
<svelte:fragment slot="moreInformation">
|
||||
{#await getAssetCount()}
|
||||
<LoadingSpinner />
|
||||
{:then data}
|
||||
<div>
|
||||
<p>{data.videos.toLocaleString($locale)} Videos</p>
|
||||
<p>{data.photos.toLocaleString($locale)} Photos</p>
|
||||
</div>
|
||||
{/await}
|
||||
</svelte:fragment>
|
||||
</SideBarButton>
|
||||
</a>
|
||||
<a data-sveltekit-preload-data="hover" data-sveltekit-noscroll href={AppRoute.EXPLORE} draggable="false">
|
||||
<SideBarButton title="Explore" logo={Magnify} isSelected={$page.route.id === '/(user)/explore'} />
|
||||
</a>
|
||||
<a data-sveltekit-preload-data="hover" href={AppRoute.MAP} draggable="false">
|
||||
<SideBarButton title="Map" logo={Map} isSelected={$page.route.id === '/(user)/map'} />
|
||||
</a>
|
||||
<a data-sveltekit-preload-data="hover" href={AppRoute.SHARING} draggable="false">
|
||||
<SideBarButton
|
||||
title="Sharing"
|
||||
logo={isSharingSelected ? AccountMultiple : AccountMultipleOutline}
|
||||
isSelected={isSharingSelected}
|
||||
>
|
||||
<svelte:fragment slot="moreInformation">
|
||||
{#await getAlbumCount()}
|
||||
<LoadingSpinner />
|
||||
{:then data}
|
||||
<div>
|
||||
<p>{data.shared.toLocaleString($locale)} Albums</p>
|
||||
</div>
|
||||
{/await}
|
||||
</svelte:fragment>
|
||||
</SideBarButton>
|
||||
</a>
|
||||
|
||||
<div class="text-xs dark:text-immich-dark-fg transition-all duration-200">
|
||||
<p class="p-6 hidden md:block group-hover:sm:block">LIBRARY</p>
|
||||
<hr class="mt-8 mb-[31px] mx-4 block md:hidden group-hover:sm:hidden" />
|
||||
</div>
|
||||
<a data-sveltekit-preload-data="hover" href={AppRoute.FAVORITES} draggable="false">
|
||||
<SideBarButton
|
||||
title="Favorites"
|
||||
logo={isFavoritesSelected ? HeartMultiple : HeartMultipleOutline}
|
||||
isSelected={isFavoritesSelected}
|
||||
>
|
||||
<svelte:fragment slot="moreInformation">
|
||||
{#await getFavoriteCount()}
|
||||
<LoadingSpinner />
|
||||
{:then data}
|
||||
<div>
|
||||
<p>{data.favorites} Favorites</p>
|
||||
</div>
|
||||
{/await}
|
||||
</svelte:fragment>
|
||||
</SideBarButton>
|
||||
</a>
|
||||
<a data-sveltekit-preload-data="hover" href={AppRoute.ALBUMS} draggable="false">
|
||||
<SideBarButton
|
||||
title="Albums"
|
||||
logo={ImageAlbum}
|
||||
flippedLogo={true}
|
||||
isSelected={$page.route.id === '/(user)/albums'}
|
||||
>
|
||||
<svelte:fragment slot="moreInformation">
|
||||
{#await getAlbumCount()}
|
||||
<LoadingSpinner />
|
||||
{:then data}
|
||||
<div>
|
||||
<p>{data.owned.toLocaleString($locale)} Albums</p>
|
||||
</div>
|
||||
{/await}
|
||||
</svelte:fragment>
|
||||
</SideBarButton>
|
||||
</a>
|
||||
<a data-sveltekit-preload-data="hover" href={AppRoute.ARCHIVE} draggable="false">
|
||||
<SideBarButton
|
||||
title="Archive"
|
||||
logo={ArchiveArrowDownOutline}
|
||||
isSelected={$page.route.id === '/(user)/archive'}
|
||||
>
|
||||
<svelte:fragment slot="moreInformation">
|
||||
{#await getArchivedAssetsCount()}
|
||||
<LoadingSpinner />
|
||||
{:then data}
|
||||
<div>
|
||||
<p>{data.videos.toLocaleString($locale)} Videos</p>
|
||||
<p>{data.photos.toLocaleString($locale)} Photos</p>
|
||||
</div>
|
||||
{/await}
|
||||
</svelte:fragment>
|
||||
</SideBarButton>
|
||||
</a>
|
||||
<div class="text-xs dark:text-immich-dark-fg transition-all duration-200">
|
||||
<p class="p-6 hidden md:block group-hover:sm:block">LIBRARY</p>
|
||||
<hr class="mt-8 mb-[31px] mx-4 block md:hidden group-hover:sm:hidden" />
|
||||
</div>
|
||||
<a data-sveltekit-preload-data="hover" href={AppRoute.FAVORITES} draggable="false">
|
||||
<SideBarButton
|
||||
title="Favorites"
|
||||
logo={isFavoritesSelected ? HeartMultiple : HeartMultipleOutline}
|
||||
isSelected={isFavoritesSelected}
|
||||
>
|
||||
<svelte:fragment slot="moreInformation">
|
||||
{#await getFavoriteCount()}
|
||||
<LoadingSpinner />
|
||||
{:then data}
|
||||
<div>
|
||||
<p>{data.favorites} Favorites</p>
|
||||
</div>
|
||||
{/await}
|
||||
</svelte:fragment>
|
||||
</SideBarButton>
|
||||
</a>
|
||||
<a data-sveltekit-preload-data="hover" href={AppRoute.ALBUMS} draggable="false">
|
||||
<SideBarButton title="Albums" logo={ImageAlbum} flippedLogo={true} isSelected={$page.route.id === '/(user)/albums'}>
|
||||
<svelte:fragment slot="moreInformation">
|
||||
{#await getAlbumCount()}
|
||||
<LoadingSpinner />
|
||||
{:then data}
|
||||
<div>
|
||||
<p>{data.owned.toLocaleString($locale)} Albums</p>
|
||||
</div>
|
||||
{/await}
|
||||
</svelte:fragment>
|
||||
</SideBarButton>
|
||||
</a>
|
||||
<a data-sveltekit-preload-data="hover" href={AppRoute.ARCHIVE} draggable="false">
|
||||
<SideBarButton title="Archive" logo={ArchiveArrowDownOutline} isSelected={$page.route.id === '/(user)/archive'}>
|
||||
<svelte:fragment slot="moreInformation">
|
||||
{#await getArchivedAssetsCount()}
|
||||
<LoadingSpinner />
|
||||
{:then data}
|
||||
<div>
|
||||
<p>{data.videos.toLocaleString($locale)} Videos</p>
|
||||
<p>{data.photos.toLocaleString($locale)} Photos</p>
|
||||
</div>
|
||||
{/await}
|
||||
</svelte:fragment>
|
||||
</SideBarButton>
|
||||
</a>
|
||||
|
||||
<!-- Status Box -->
|
||||
<div class="mb-6 mt-auto">
|
||||
<StatusBox />
|
||||
</div>
|
||||
<!-- Status Box -->
|
||||
<div class="mb-6 mt-auto">
|
||||
<StatusBox />
|
||||
</div>
|
||||
</SideBarSection>
|
||||
|
|
|
|||
|
|
@ -1,113 +1,109 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import Cloud from 'svelte-material-icons/Cloud.svelte';
|
||||
import Dns from 'svelte-material-icons/Dns.svelte';
|
||||
import LoadingSpinner from './loading-spinner.svelte';
|
||||
import { api, ServerInfoResponseDto } from '@api';
|
||||
import { asByteUnitString } from '../../utils/byte-units';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import Cloud from 'svelte-material-icons/Cloud.svelte';
|
||||
import Dns from 'svelte-material-icons/Dns.svelte';
|
||||
import LoadingSpinner from './loading-spinner.svelte';
|
||||
import { api, ServerInfoResponseDto } from '@api';
|
||||
import { asByteUnitString } from '../../utils/byte-units';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
|
||||
let isServerOk = true;
|
||||
let serverVersion = '';
|
||||
let serverInfo: ServerInfoResponseDto;
|
||||
let pingServerInterval: NodeJS.Timer;
|
||||
let isServerOk = true;
|
||||
let serverVersion = '';
|
||||
let serverInfo: ServerInfoResponseDto;
|
||||
let pingServerInterval: NodeJS.Timer;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const { data: version } = await api.serverInfoApi.getServerVersion();
|
||||
onMount(async () => {
|
||||
try {
|
||||
const { data: version } = await api.serverInfoApi.getServerVersion();
|
||||
|
||||
serverVersion = `v${version.major}.${version.minor}.${version.patch}`;
|
||||
serverVersion = `v${version.major}.${version.minor}.${version.patch}`;
|
||||
|
||||
const { data: serverInfoRes } = await api.serverInfoApi.getServerInfo();
|
||||
serverInfo = serverInfoRes;
|
||||
getStorageUsagePercentage();
|
||||
} catch (e) {
|
||||
console.log('Error [StatusBox] [onMount]');
|
||||
isServerOk = false;
|
||||
}
|
||||
const { data: serverInfoRes } = await api.serverInfoApi.getServerInfo();
|
||||
serverInfo = serverInfoRes;
|
||||
getStorageUsagePercentage();
|
||||
} catch (e) {
|
||||
console.log('Error [StatusBox] [onMount]');
|
||||
isServerOk = false;
|
||||
}
|
||||
|
||||
pingServerInterval = setInterval(async () => {
|
||||
try {
|
||||
const { data: pingReponse } = await api.serverInfoApi.pingServer();
|
||||
pingServerInterval = setInterval(async () => {
|
||||
try {
|
||||
const { data: pingReponse } = await api.serverInfoApi.pingServer();
|
||||
|
||||
if (pingReponse.res === 'pong') isServerOk = true;
|
||||
else isServerOk = false;
|
||||
if (pingReponse.res === 'pong') isServerOk = true;
|
||||
else isServerOk = false;
|
||||
|
||||
const { data: serverInfoRes } = await api.serverInfoApi.getServerInfo();
|
||||
serverInfo = serverInfoRes;
|
||||
} catch (e) {
|
||||
console.log('Error [StatusBox] [pingServerInterval]', e);
|
||||
isServerOk = false;
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
const { data: serverInfoRes } = await api.serverInfoApi.getServerInfo();
|
||||
serverInfo = serverInfoRes;
|
||||
} catch (e) {
|
||||
console.log('Error [StatusBox] [pingServerInterval]', e);
|
||||
isServerOk = false;
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
onDestroy(() => clearInterval(pingServerInterval));
|
||||
onDestroy(() => clearInterval(pingServerInterval));
|
||||
|
||||
const getStorageUsagePercentage = () => {
|
||||
return Math.round((serverInfo?.diskUseRaw / serverInfo?.diskSizeRaw) * 100);
|
||||
};
|
||||
const getStorageUsagePercentage = () => {
|
||||
return Math.round((serverInfo?.diskUseRaw / serverInfo?.diskSizeRaw) * 100);
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="dark:text-immich-dark-fg">
|
||||
<div class="storage-status grid grid-cols-[64px_auto]">
|
||||
<div
|
||||
class="pl-5 pr-6 text-immich-primary dark:text-immich-dark-primary pb-[2.15rem] group-hover:sm:pb-0 md:pb-0"
|
||||
>
|
||||
<Cloud size={'24'} />
|
||||
</div>
|
||||
<div class="hidden md:block group-hover:sm:block">
|
||||
<p class="text-sm font-medium text-immich-primary dark:text-immich-dark-primary">Storage</p>
|
||||
{#if serverInfo}
|
||||
<div class="w-full bg-gray-200 rounded-full h-[7px] dark:bg-gray-700 my-2">
|
||||
<!-- style={`width: ${$downloadAssets[fileName]}%`} -->
|
||||
<div
|
||||
class="bg-immich-primary dark:bg-immich-dark-primary h-[7px] rounded-full"
|
||||
style="width: {getStorageUsagePercentage()}%"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs">
|
||||
{asByteUnitString(serverInfo?.diskUseRaw, $locale)} of
|
||||
{asByteUnitString(serverInfo?.diskSizeRaw, $locale)} used
|
||||
</p>
|
||||
{:else}
|
||||
<div class="mt-2">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<hr class="ml-5 my-4 dark:border-immich-dark-gray" />
|
||||
</div>
|
||||
<div class="server-status grid grid-cols-[64px_auto]">
|
||||
<div
|
||||
class="pl-5 pr-6 text-immich-primary dark:text-immich-dark-primary pb-11 md:pb-0 group-hover:sm:pb-0"
|
||||
>
|
||||
<Dns size={'24'} />
|
||||
</div>
|
||||
<div class="text-xs hidden md:block group-hover:sm:block">
|
||||
<p class="text-sm font-medium text-immich-primary dark:text-immich-dark-primary">Server</p>
|
||||
<div class="storage-status grid grid-cols-[64px_auto]">
|
||||
<div class="pl-5 pr-6 text-immich-primary dark:text-immich-dark-primary pb-[2.15rem] group-hover:sm:pb-0 md:pb-0">
|
||||
<Cloud size={'24'} />
|
||||
</div>
|
||||
<div class="hidden md:block group-hover:sm:block">
|
||||
<p class="text-sm font-medium text-immich-primary dark:text-immich-dark-primary">Storage</p>
|
||||
{#if serverInfo}
|
||||
<div class="w-full bg-gray-200 rounded-full h-[7px] dark:bg-gray-700 my-2">
|
||||
<!-- style={`width: ${$downloadAssets[fileName]}%`} -->
|
||||
<div
|
||||
class="bg-immich-primary dark:bg-immich-dark-primary h-[7px] rounded-full"
|
||||
style="width: {getStorageUsagePercentage()}%"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs">
|
||||
{asByteUnitString(serverInfo?.diskUseRaw, $locale)} of
|
||||
{asByteUnitString(serverInfo?.diskSizeRaw, $locale)} used
|
||||
</p>
|
||||
{:else}
|
||||
<div class="mt-2">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<hr class="ml-5 my-4 dark:border-immich-dark-gray" />
|
||||
</div>
|
||||
<div class="server-status grid grid-cols-[64px_auto]">
|
||||
<div class="pl-5 pr-6 text-immich-primary dark:text-immich-dark-primary pb-11 md:pb-0 group-hover:sm:pb-0">
|
||||
<Dns size={'24'} />
|
||||
</div>
|
||||
<div class="text-xs hidden md:block group-hover:sm:block">
|
||||
<p class="text-sm font-medium text-immich-primary dark:text-immich-dark-primary">Server</p>
|
||||
|
||||
<div class="flex justify-items-center justify-between mt-2">
|
||||
<p>Status</p>
|
||||
<div class="flex justify-items-center justify-between mt-2">
|
||||
<p>Status</p>
|
||||
|
||||
{#if isServerOk}
|
||||
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">Online</p>
|
||||
{:else}
|
||||
<p class="font-medium text-red-500">Offline</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isServerOk}
|
||||
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">Online</p>
|
||||
{:else}
|
||||
<p class="font-medium text-red-500">Offline</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-items-center justify-between mt-2">
|
||||
<p>Version</p>
|
||||
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">
|
||||
{serverVersion}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div>
|
||||
<div class="flex justify-items-center justify-between mt-2">
|
||||
<p>Version</p>
|
||||
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">
|
||||
{serverVersion}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div>
|
||||
<hr class="ml-5 my-4" />
|
||||
</div>
|
||||
<button class="text-xs ml-5 underline hover:cursor-pointer text-immich-primary" on:click={() => goto('/changelog')}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,35 @@
|
|||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { colorTheme } from '$lib/stores/preferences.store';
|
||||
import IconButton from '../elements/buttons/icon-button.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { colorTheme } from '$lib/stores/preferences.store';
|
||||
import IconButton from '../elements/buttons/icon-button.svelte';
|
||||
|
||||
const toggleTheme = () => {
|
||||
$colorTheme = $colorTheme === 'dark' ? 'light' : 'dark';
|
||||
};
|
||||
const toggleTheme = () => {
|
||||
$colorTheme = $colorTheme === 'dark' ? 'light' : 'dark';
|
||||
};
|
||||
|
||||
$: {
|
||||
if (browser) {
|
||||
if ($colorTheme === 'light') {
|
||||
document.documentElement.classList.remove('dark');
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
}
|
||||
}
|
||||
$: {
|
||||
if (browser) {
|
||||
if ($colorTheme === 'light') {
|
||||
document.documentElement.classList.remove('dark');
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<IconButton on:click={toggleTheme} title="Toggle theme">
|
||||
{#if $colorTheme === 'light'}
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"
|
||||
><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" /></svg
|
||||
>
|
||||
{:else}
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"
|
||||
><path
|
||||
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
/></svg
|
||||
>
|
||||
{/if}
|
||||
{#if $colorTheme === 'light'}
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"
|
||||
><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" /></svg
|
||||
>
|
||||
{:else}
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"
|
||||
><path
|
||||
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
/></svg
|
||||
>
|
||||
{/if}
|
||||
</IconButton>
|
||||
|
|
|
|||
|
|
@ -1,67 +1,64 @@
|
|||
<script lang="ts">
|
||||
import type { UploadAsset } from '$lib/models/upload-asset';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { asByteUnitString } from '$lib/utils/byte-units';
|
||||
import { fade } from 'svelte/transition';
|
||||
import ImmichLogo from './immich-logo.svelte';
|
||||
import type { UploadAsset } from '$lib/models/upload-asset';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { asByteUnitString } from '$lib/utils/byte-units';
|
||||
import { fade } from 'svelte/transition';
|
||||
import ImmichLogo from './immich-logo.svelte';
|
||||
|
||||
export let uploadAsset: UploadAsset;
|
||||
export let uploadAsset: UploadAsset;
|
||||
|
||||
let showFallbackImage = false;
|
||||
const previewURL = URL.createObjectURL(uploadAsset.file);
|
||||
let showFallbackImage = false;
|
||||
const previewURL = URL.createObjectURL(uploadAsset.file);
|
||||
</script>
|
||||
|
||||
<div
|
||||
in:fade={{ duration: 250 }}
|
||||
out:fade={{ duration: 100 }}
|
||||
class="text-xs mt-3 rounded-lg bg-immich-bg grid grid-cols-[70px_auto] gap-2 h-[70px]"
|
||||
in:fade={{ duration: 250 }}
|
||||
out:fade={{ duration: 100 }}
|
||||
class="text-xs mt-3 rounded-lg bg-immich-bg grid grid-cols-[70px_auto] gap-2 h-[70px]"
|
||||
>
|
||||
<div class="relative">
|
||||
{#if showFallbackImage}
|
||||
<div in:fade={{ duration: 250 }}>
|
||||
<ImmichLogo class="h-[70px] w-[70px] object-cover rounded-tl-lg rounded-bl-lg" />
|
||||
</div>
|
||||
{:else}
|
||||
<img
|
||||
in:fade={{ duration: 250 }}
|
||||
on:load={() => {
|
||||
URL.revokeObjectURL(previewURL);
|
||||
}}
|
||||
on:error={() => {
|
||||
URL.revokeObjectURL(previewURL);
|
||||
showFallbackImage = true;
|
||||
}}
|
||||
src={previewURL}
|
||||
alt="Preview of asset"
|
||||
class="h-[70px] w-[70px] object-cover rounded-tl-lg rounded-bl-lg"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
<div class="relative">
|
||||
{#if showFallbackImage}
|
||||
<div in:fade={{ duration: 250 }}>
|
||||
<ImmichLogo class="h-[70px] w-[70px] object-cover rounded-tl-lg rounded-bl-lg" />
|
||||
</div>
|
||||
{:else}
|
||||
<img
|
||||
in:fade={{ duration: 250 }}
|
||||
on:load={() => {
|
||||
URL.revokeObjectURL(previewURL);
|
||||
}}
|
||||
on:error={() => {
|
||||
URL.revokeObjectURL(previewURL);
|
||||
showFallbackImage = true;
|
||||
}}
|
||||
src={previewURL}
|
||||
alt="Preview of asset"
|
||||
class="h-[70px] w-[70px] object-cover rounded-tl-lg rounded-bl-lg"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="bottom-0 left-0 absolute w-full h-[25px] bg-immich-primary/30">
|
||||
<p
|
||||
class="absolute bottom-1 right-1 object-right-bottom text-gray-50/95 font-semibold stroke-immich-primary uppercase"
|
||||
>
|
||||
.{uploadAsset.fileExtension}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bottom-0 left-0 absolute w-full h-[25px] bg-immich-primary/30">
|
||||
<p
|
||||
class="absolute bottom-1 right-1 object-right-bottom text-gray-50/95 font-semibold stroke-immich-primary uppercase"
|
||||
>
|
||||
.{uploadAsset.fileExtension}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2 pr-4 flex flex-col justify-between">
|
||||
<input
|
||||
disabled
|
||||
class="bg-gray-100 border w-full p-1 rounded-md text-[10px] px-2"
|
||||
value={`[${asByteUnitString(uploadAsset.file.size, $locale)}] ${uploadAsset.file.name}`}
|
||||
/>
|
||||
<div class="p-2 pr-4 flex flex-col justify-between">
|
||||
<input
|
||||
disabled
|
||||
class="bg-gray-100 border w-full p-1 rounded-md text-[10px] px-2"
|
||||
value={`[${asByteUnitString(uploadAsset.file.size, $locale)}] ${uploadAsset.file.name}`}
|
||||
/>
|
||||
|
||||
<div class="w-full bg-gray-300 h-[15px] rounded-md mt-[5px] text-white relative">
|
||||
<div
|
||||
class="bg-immich-primary h-[15px] rounded-md transition-all"
|
||||
style={`width: ${uploadAsset.progress}%`}
|
||||
/>
|
||||
<p class="absolute h-full w-full text-center top-0 text-[10px]">
|
||||
{uploadAsset.progress}/100
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full bg-gray-300 h-[15px] rounded-md mt-[5px] text-white relative">
|
||||
<div class="bg-immich-primary h-[15px] rounded-md transition-all" style={`width: ${uploadAsset.progress}%`} />
|
||||
<p class="absolute h-full w-full text-center top-0 text-[10px]">
|
||||
{uploadAsset.progress}/100
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,82 +1,82 @@
|
|||
<script lang="ts">
|
||||
import { quartInOut } from 'svelte/easing';
|
||||
import { scale, fade } from 'svelte/transition';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import CloudUploadOutline from 'svelte-material-icons/CloudUploadOutline.svelte';
|
||||
import WindowMinimize from 'svelte-material-icons/WindowMinimize.svelte';
|
||||
import { notificationController, NotificationType } from './notification/notification';
|
||||
import UploadAssetPreview from './upload-asset-preview.svelte';
|
||||
import { quartInOut } from 'svelte/easing';
|
||||
import { scale, fade } from 'svelte/transition';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import CloudUploadOutline from 'svelte-material-icons/CloudUploadOutline.svelte';
|
||||
import WindowMinimize from 'svelte-material-icons/WindowMinimize.svelte';
|
||||
import { notificationController, NotificationType } from './notification/notification';
|
||||
import UploadAssetPreview from './upload-asset-preview.svelte';
|
||||
|
||||
let showDetail = true;
|
||||
let uploadLength = 0;
|
||||
let isUploading = false;
|
||||
let showDetail = true;
|
||||
let uploadLength = 0;
|
||||
let isUploading = false;
|
||||
|
||||
// Reactive action to update asset uploadLength whenever there is a new one added to the list
|
||||
$: {
|
||||
if ($uploadAssetsStore.length != uploadLength) {
|
||||
uploadLength = $uploadAssetsStore.length;
|
||||
}
|
||||
}
|
||||
// Reactive action to update asset uploadLength whenever there is a new one added to the list
|
||||
$: {
|
||||
if ($uploadAssetsStore.length != uploadLength) {
|
||||
uploadLength = $uploadAssetsStore.length;
|
||||
}
|
||||
}
|
||||
|
||||
uploadAssetsStore.isUploading.subscribe((value) => {
|
||||
isUploading = value;
|
||||
});
|
||||
uploadAssetsStore.isUploading.subscribe((value) => {
|
||||
isUploading = value;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isUploading}
|
||||
<div
|
||||
in:fade={{ duration: 250 }}
|
||||
out:fade={{ duration: 250, delay: 1000 }}
|
||||
on:outroend={() => {
|
||||
notificationController.show({
|
||||
message: 'Upload success, refresh the page to see new upload assets',
|
||||
type: NotificationType.Info
|
||||
});
|
||||
}}
|
||||
class="absolute right-6 bottom-6 z-[10000]"
|
||||
>
|
||||
{#if showDetail}
|
||||
<div
|
||||
in:scale={{ duration: 250, easing: quartInOut }}
|
||||
class="bg-gray-200 p-4 text-sm w-[300px] rounded-lg shadow-sm border"
|
||||
>
|
||||
<div class="flex justify-between place-item-center mb-4">
|
||||
<p class="text-xs text-gray-500">UPLOADING {$uploadAssetsStore.length}</p>
|
||||
<button
|
||||
on:click={() => (showDetail = false)}
|
||||
class="w-[20px] h-[20px] bg-gray-50 rounded-full flex place-items-center place-content-center transition-colors hover:bg-gray-100"
|
||||
>
|
||||
<WindowMinimize />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
in:fade={{ duration: 250 }}
|
||||
out:fade={{ duration: 250, delay: 1000 }}
|
||||
on:outroend={() => {
|
||||
notificationController.show({
|
||||
message: 'Upload success, refresh the page to see new upload assets',
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
}}
|
||||
class="absolute right-6 bottom-6 z-[10000]"
|
||||
>
|
||||
{#if showDetail}
|
||||
<div
|
||||
in:scale={{ duration: 250, easing: quartInOut }}
|
||||
class="bg-gray-200 p-4 text-sm w-[300px] rounded-lg shadow-sm border"
|
||||
>
|
||||
<div class="flex justify-between place-item-center mb-4">
|
||||
<p class="text-xs text-gray-500">UPLOADING {$uploadAssetsStore.length}</p>
|
||||
<button
|
||||
on:click={() => (showDetail = false)}
|
||||
class="w-[20px] h-[20px] bg-gray-50 rounded-full flex place-items-center place-content-center transition-colors hover:bg-gray-100"
|
||||
>
|
||||
<WindowMinimize />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[400px] overflow-y-auto pr-2 rounded-lg immich-scrollbar">
|
||||
{#each $uploadAssetsStore as uploadAsset}
|
||||
{#key uploadAsset.id}
|
||||
<UploadAssetPreview {uploadAsset} />
|
||||
{/key}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-full">
|
||||
<button
|
||||
in:scale={{ duration: 250, easing: quartInOut }}
|
||||
on:click={() => (showDetail = true)}
|
||||
class="absolute -top-4 -left-4 text-xs rounded-full w-10 h-10 p-5 flex place-items-center place-content-center bg-immich-primary text-gray-200"
|
||||
>
|
||||
{$uploadAssetsStore.length}
|
||||
</button>
|
||||
<button
|
||||
in:scale={{ duration: 250, easing: quartInOut }}
|
||||
on:click={() => (showDetail = true)}
|
||||
class="bg-gray-300 p-5 rounded-full w-16 h-16 flex place-items-center place-content-center text-sm shadow-lg"
|
||||
>
|
||||
<div class="animate-pulse">
|
||||
<CloudUploadOutline size="30" color="#4250af" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="max-h-[400px] overflow-y-auto pr-2 rounded-lg immich-scrollbar">
|
||||
{#each $uploadAssetsStore as uploadAsset}
|
||||
{#key uploadAsset.id}
|
||||
<UploadAssetPreview {uploadAsset} />
|
||||
{/key}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-full">
|
||||
<button
|
||||
in:scale={{ duration: 250, easing: quartInOut }}
|
||||
on:click={() => (showDetail = true)}
|
||||
class="absolute -top-4 -left-4 text-xs rounded-full w-10 h-10 p-5 flex place-items-center place-content-center bg-immich-primary text-gray-200"
|
||||
>
|
||||
{$uploadAssetsStore.length}
|
||||
</button>
|
||||
<button
|
||||
in:scale={{ duration: 250, easing: quartInOut }}
|
||||
on:click={() => (showDetail = true)}
|
||||
class="bg-gray-300 p-5 rounded-full w-16 h-16 flex place-items-center place-content-center text-sm shadow-lg"
|
||||
>
|
||||
<div class="animate-pulse">
|
||||
<CloudUploadOutline size="30" color="#4250af" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,79 +1,78 @@
|
|||
<script lang="ts" context="module">
|
||||
export type Color = 'primary' | 'pink' | 'red' | 'yellow' | 'blue' | 'green';
|
||||
export type Size = 'full' | 'sm' | 'md' | 'lg';
|
||||
export type Color = 'primary' | 'pink' | 'red' | 'yellow' | 'blue' | 'green';
|
||||
export type Size = 'full' | 'sm' | 'md' | 'lg';
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { imageLoad } from '$lib/utils/image-load';
|
||||
import { api, UserResponseDto } from '@api';
|
||||
import { imageLoad } from '$lib/utils/image-load';
|
||||
import { api, UserResponseDto } from '@api';
|
||||
|
||||
export let user: UserResponseDto;
|
||||
export let color: Color = 'primary';
|
||||
export let size: Size = 'full';
|
||||
export let rounded = true;
|
||||
export let interactive = false;
|
||||
export let showTitle = true;
|
||||
export let autoColor = false;
|
||||
let showFallback = true;
|
||||
export let user: UserResponseDto;
|
||||
export let color: Color = 'primary';
|
||||
export let size: Size = 'full';
|
||||
export let rounded = true;
|
||||
export let interactive = false;
|
||||
export let showTitle = true;
|
||||
export let autoColor = false;
|
||||
let showFallback = true;
|
||||
|
||||
const colorClasses: Record<Color, string> = {
|
||||
primary:
|
||||
'bg-immich-primary dark:bg-immich-dark-primary text-immich-dark-fg dark:text-immich-fg',
|
||||
pink: 'bg-pink-400 text-immich-bg',
|
||||
red: 'bg-red-500 text-immich-bg',
|
||||
yellow: 'bg-yellow-500 text-immich-bg',
|
||||
blue: 'bg-blue-500 text-immich-bg',
|
||||
green: 'bg-green-600 text-immich-bg'
|
||||
};
|
||||
const colorClasses: Record<Color, string> = {
|
||||
primary: 'bg-immich-primary dark:bg-immich-dark-primary text-immich-dark-fg dark:text-immich-fg',
|
||||
pink: 'bg-pink-400 text-immich-bg',
|
||||
red: 'bg-red-500 text-immich-bg',
|
||||
yellow: 'bg-yellow-500 text-immich-bg',
|
||||
blue: 'bg-blue-500 text-immich-bg',
|
||||
green: 'bg-green-600 text-immich-bg',
|
||||
};
|
||||
|
||||
const sizeClasses: Record<Size, string> = {
|
||||
full: 'w-full h-full',
|
||||
sm: 'w-7 h-7',
|
||||
md: 'w-12 h-12',
|
||||
lg: 'w-20 h-20'
|
||||
};
|
||||
const sizeClasses: Record<Size, string> = {
|
||||
full: 'w-full h-full',
|
||||
sm: 'w-7 h-7',
|
||||
md: 'w-12 h-12',
|
||||
lg: 'w-20 h-20',
|
||||
};
|
||||
|
||||
// Get color based on the user UUID.
|
||||
function getUserColor() {
|
||||
const seed = parseInt(user.id.split('-')[0], 16);
|
||||
const colors = Object.keys(colorClasses).filter((color) => color !== 'primary') as Color[];
|
||||
const randomIndex = seed % colors.length;
|
||||
return colors[randomIndex];
|
||||
}
|
||||
// Get color based on the user UUID.
|
||||
function getUserColor() {
|
||||
const seed = parseInt(user.id.split('-')[0], 16);
|
||||
const colors = Object.keys(colorClasses).filter((color) => color !== 'primary') as Color[];
|
||||
const randomIndex = seed % colors.length;
|
||||
return colors[randomIndex];
|
||||
}
|
||||
|
||||
$: colorClass = colorClasses[autoColor ? getUserColor() : color];
|
||||
$: sizeClass = sizeClasses[size];
|
||||
$: title = `${user.firstName} ${user.lastName} (${user.email})`;
|
||||
$: interactiveClass = interactive
|
||||
? 'border-2 border-immich-primary hover:border-immich-dark-primary dark:hover:border-immich-primary dark:border-immich-dark-primary transition-colors'
|
||||
: '';
|
||||
$: colorClass = colorClasses[autoColor ? getUserColor() : color];
|
||||
$: sizeClass = sizeClasses[size];
|
||||
$: title = `${user.firstName} ${user.lastName} (${user.email})`;
|
||||
$: interactiveClass = interactive
|
||||
? 'border-2 border-immich-primary hover:border-immich-dark-primary dark:hover:border-immich-primary dark:border-immich-dark-primary transition-colors'
|
||||
: '';
|
||||
</script>
|
||||
|
||||
<figure
|
||||
class="{sizeClass} {colorClass} {interactiveClass} shadow-md overflow-hidden"
|
||||
class:rounded-full={rounded}
|
||||
title={showTitle ? title : undefined}
|
||||
class="{sizeClass} {colorClass} {interactiveClass} shadow-md overflow-hidden"
|
||||
class:rounded-full={rounded}
|
||||
title={showTitle ? title : undefined}
|
||||
>
|
||||
{#if user.profileImagePath}
|
||||
<img
|
||||
src={api.getProfileImageUrl(user.id)}
|
||||
alt="Profile image of {title}"
|
||||
class="object-cover w-full h-full"
|
||||
class:hidden={showFallback}
|
||||
draggable="false"
|
||||
use:imageLoad
|
||||
on:image-load={() => (showFallback = false)}
|
||||
/>
|
||||
{/if}
|
||||
{#if showFallback}
|
||||
<span
|
||||
class="flex justify-center items-center w-full h-full select-none"
|
||||
class:text-xs={size === 'sm'}
|
||||
class:text-lg={size === 'lg'}
|
||||
class:font-medium={!autoColor}
|
||||
class:font-semibold={autoColor}
|
||||
>
|
||||
{((user.firstName[0] || '') + (user.lastName[0] || '')).toUpperCase()}
|
||||
</span>
|
||||
{/if}
|
||||
{#if user.profileImagePath}
|
||||
<img
|
||||
src={api.getProfileImageUrl(user.id)}
|
||||
alt="Profile image of {title}"
|
||||
class="object-cover w-full h-full"
|
||||
class:hidden={showFallback}
|
||||
draggable="false"
|
||||
use:imageLoad
|
||||
on:image-load={() => (showFallback = false)}
|
||||
/>
|
||||
{/if}
|
||||
{#if showFallback}
|
||||
<span
|
||||
class="flex justify-center items-center w-full h-full select-none"
|
||||
class:text-xs={size === 'sm'}
|
||||
class:text-lg={size === 'lg'}
|
||||
class:font-medium={!autoColor}
|
||||
class:font-semibold={autoColor}
|
||||
>
|
||||
{((user.firstName[0] || '') + (user.lastName[0] || '')).toUpperCase()}
|
||||
</span>
|
||||
{/if}
|
||||
</figure>
|
||||
|
|
|
|||
|
|
@ -1,80 +1,76 @@
|
|||
<script lang="ts">
|
||||
import { getGithubVersion } from '$lib/utils/get-github-version';
|
||||
import { onMount } from 'svelte';
|
||||
import FullScreenModal from './full-screen-modal.svelte';
|
||||
import type { ServerVersionReponseDto } from '@api';
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
import { getGithubVersion } from '$lib/utils/get-github-version';
|
||||
import { onMount } from 'svelte';
|
||||
import FullScreenModal from './full-screen-modal.svelte';
|
||||
import type { ServerVersionReponseDto } from '@api';
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
|
||||
export let serverVersion: ServerVersionReponseDto;
|
||||
export let serverVersion: ServerVersionReponseDto;
|
||||
|
||||
let showModal = false;
|
||||
let githubVersion: string;
|
||||
$: serverVersionName = semverToName(serverVersion);
|
||||
let showModal = false;
|
||||
let githubVersion: string;
|
||||
$: serverVersionName = semverToName(serverVersion);
|
||||
|
||||
function semverToName({ major, minor, patch }: ServerVersionReponseDto) {
|
||||
return `v${major}.${minor}.${patch}`;
|
||||
}
|
||||
function semverToName({ major, minor, patch }: ServerVersionReponseDto) {
|
||||
return `v${major}.${minor}.${patch}`;
|
||||
}
|
||||
|
||||
function onAcknowledge() {
|
||||
// Store server version to prevent the notification
|
||||
// from showing again.
|
||||
localStorage.setItem('appVersion', githubVersion);
|
||||
showModal = false;
|
||||
}
|
||||
function onAcknowledge() {
|
||||
// Store server version to prevent the notification
|
||||
// from showing again.
|
||||
localStorage.setItem('appVersion', githubVersion);
|
||||
showModal = false;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
githubVersion = await getGithubVersion();
|
||||
if (localStorage.getItem('appVersion') === githubVersion) {
|
||||
// Updated version has already been acknowledged.
|
||||
return;
|
||||
}
|
||||
onMount(async () => {
|
||||
try {
|
||||
githubVersion = await getGithubVersion();
|
||||
if (localStorage.getItem('appVersion') === githubVersion) {
|
||||
// Updated version has already been acknowledged.
|
||||
return;
|
||||
}
|
||||
|
||||
if (githubVersion !== serverVersionName) {
|
||||
showModal = true;
|
||||
}
|
||||
} catch (err) {
|
||||
// Only log any errors that occur.
|
||||
console.error('Error [VersionAnnouncementBox]:', err);
|
||||
}
|
||||
});
|
||||
if (githubVersion !== serverVersionName) {
|
||||
showModal = true;
|
||||
}
|
||||
} catch (err) {
|
||||
// Only log any errors that occur.
|
||||
console.error('Error [VersionAnnouncementBox]:', err);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if showModal}
|
||||
<FullScreenModal on:clickOutside={() => (showModal = false)}>
|
||||
<div
|
||||
class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray shadow-sm max-w-lg rounded-3xl py-10 px-8 dark:text-immich-dark-fg"
|
||||
>
|
||||
<p class="text-2xl mb-4">🎉 NEW VERSION AVAILABLE 🎉</p>
|
||||
<FullScreenModal on:clickOutside={() => (showModal = false)}>
|
||||
<div
|
||||
class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray shadow-sm max-w-lg rounded-3xl py-10 px-8 dark:text-immich-dark-fg"
|
||||
>
|
||||
<p class="text-2xl mb-4">🎉 NEW VERSION AVAILABLE 🎉</p>
|
||||
|
||||
<div>
|
||||
Hi friend, there is a new release of
|
||||
<span class="font-immich-title text-immich-primary dark:text-immich-dark-primary font-bold"
|
||||
>IMMICH</span
|
||||
>, please take your time to visit the
|
||||
<span class="underline font-medium"
|
||||
><a
|
||||
href="https://github.com/immich-app/immich/releases/latest"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">release notes</a
|
||||
></span
|
||||
>
|
||||
and ensure your <code>docker-compose</code>, and <code>.env</code> setup is up-to-date to prevent
|
||||
any misconfigurations, especially if you use WatchTower or any mechanism that handles updating
|
||||
your application automatically.
|
||||
</div>
|
||||
<div>
|
||||
Hi friend, there is a new release of
|
||||
<span class="font-immich-title text-immich-primary dark:text-immich-dark-primary font-bold">IMMICH</span>,
|
||||
please take your time to visit the
|
||||
<span class="underline font-medium"
|
||||
><a href="https://github.com/immich-app/immich/releases/latest" target="_blank" rel="noopener noreferrer"
|
||||
>release notes</a
|
||||
></span
|
||||
>
|
||||
and ensure your <code>docker-compose</code>, and <code>.env</code> setup is up-to-date to prevent any misconfigurations,
|
||||
especially if you use WatchTower or any mechanism that handles updating your application automatically.
|
||||
</div>
|
||||
|
||||
<div class="font-medium mt-4">Your friend, Alex</div>
|
||||
<div class="font-medium mt-4">Your friend, Alex</div>
|
||||
|
||||
<div class="font-sm mt-8">
|
||||
<code>Server Version: {serverVersionName}</code>
|
||||
<br />
|
||||
<code>Latest Version: {githubVersion}</code>
|
||||
</div>
|
||||
<div class="font-sm mt-8">
|
||||
<code>Server Version: {serverVersionName}</code>
|
||||
<br />
|
||||
<code>Latest Version: {githubVersion}</code>
|
||||
</div>
|
||||
|
||||
<div class="text-right mt-8">
|
||||
<Button fullwidth on:click={onAcknowledge}>Acknowledge</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FullScreenModal>
|
||||
<div class="text-right mt-8">
|
||||
<Button fullwidth on:click={onAcknowledge}>Acknowledge</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FullScreenModal>
|
||||
{/if}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue