mirror of
https://github.com/immich-app/immich.git
synced 2024-12-28 22:51:59 +00:00
Show assets on web (#168)
* Implemented lazy loading thumbnail * Display assets as date-time grouping * Update Readme * Modify GitHub action to run from the latest update
This commit is contained in:
parent
171e7ffa77
commit
6023c3c624
14 changed files with 350 additions and 752 deletions
11
.github/workflows/build_push_docker_latest.yml
vendored
11
.github/workflows/build_push_docker_latest.yml
vendored
|
@ -14,7 +14,9 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: "main" # branch
|
||||
# ref: "main" # branch
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
- name: Set up Docker Buildx
|
||||
|
@ -41,7 +43,9 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: "main" # branch
|
||||
# ref: "main" # branch
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
- name: Set up Docker Buildx
|
||||
|
@ -68,7 +72,8 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: "main" # branch
|
||||
# ref: "main" # branch
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
- name: Set up Docker Buildx
|
||||
|
|
|
@ -67,7 +67,7 @@ This project is under heavy development, there will be continous functions, feat
|
|||
- Show curated objects on the search page
|
||||
- Shared album with users on the same server
|
||||
- Selective backup - albums can be included and excluded during the backup process.
|
||||
|
||||
- Web interface is available for administrative tasks (create new users) and view assets on the server - additional features are coming.
|
||||
|
||||
# System Requirement
|
||||
|
||||
|
|
|
@ -22,7 +22,7 @@ export class AssetService {
|
|||
constructor(
|
||||
@InjectRepository(AssetEntity)
|
||||
private assetRepository: Repository<AssetEntity>,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
public async updateThumbnailInfo(assetId: string, path: string) {
|
||||
return await this.assetRepository.update(assetId, {
|
||||
|
|
821
web/package-lock.json
generated
821
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -18,6 +18,7 @@
|
|||
"@sveltejs/kit": "next",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/cookie": "^0.4.1",
|
||||
"@types/lodash": "^4.14.182",
|
||||
"@typescript-eslint/eslint-plugin": "^5.10.1",
|
||||
"@typescript-eslint/parser": "^5.10.1",
|
||||
"autoprefixer": "^10.4.7",
|
||||
|
@ -36,10 +37,9 @@
|
|||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@fontsource/fira-mono": "^4.5.0",
|
||||
"@lukeed/uuid": "^2.0.0",
|
||||
"bcrypt": "^5.0.1",
|
||||
"cookie": "^0.4.2",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.3",
|
||||
"svelte-material-icons": "^2.0.2"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,15 +5,17 @@ type ISend = {
|
|||
path: string,
|
||||
data?: any,
|
||||
token: string
|
||||
customHeaders?: Record<string, string>,
|
||||
}
|
||||
|
||||
type IOption = {
|
||||
method: string,
|
||||
headers: Record<string, string>,
|
||||
body: any
|
||||
|
||||
}
|
||||
|
||||
async function send({ method, path, data, token }: ISend) {
|
||||
async function send({ method, path, data, token, customHeaders }: ISend) {
|
||||
const opts: IOption = { method, headers: {} } as IOption;
|
||||
|
||||
if (data) {
|
||||
|
@ -21,6 +23,11 @@ async function send({ method, path, data, token }: ISend) {
|
|||
opts.body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
if (customHeaders) {
|
||||
console.log(customHeaders);
|
||||
// opts.headers[customHeader.$1]
|
||||
}
|
||||
|
||||
if (token) {
|
||||
opts.headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
@ -36,18 +43,18 @@ async function send({ method, path, data, token }: ISend) {
|
|||
});
|
||||
}
|
||||
|
||||
export function getRequest(path: string, token: string) {
|
||||
return send({ method: 'GET', path, token });
|
||||
export function getRequest(path: string, token: string, customHeaders?: Record<string, string>) {
|
||||
return send({ method: 'GET', path, token, customHeaders });
|
||||
}
|
||||
|
||||
export function delRequest(path: string, token: string) {
|
||||
return send({ method: 'DELETE', path, token });
|
||||
export function delRequest(path: string, token: string, customHeaders?: Record<string, string>) {
|
||||
return send({ method: 'DELETE', path, token, customHeaders });
|
||||
}
|
||||
|
||||
export function postRequest(path: string, data: any, token: string) {
|
||||
return send({ method: 'POST', path, data, token });
|
||||
export function postRequest(path: string, data: any, token: string, customHeaders?: Record<string, string>) {
|
||||
return send({ method: 'POST', path, data, token, customHeaders });
|
||||
}
|
||||
|
||||
export function putRequest(path: string, data: any, token: string) {
|
||||
return send({ method: 'PUT', path, data, token });
|
||||
export function putRequest(path: string, data: any, token: string, customHeaders?: Record<string, string>) {
|
||||
return send({ method: 'PUT', path, data, token, customHeaders });
|
||||
}
|
46
web/src/lib/components/photos/immich-thumbnail.svelte
Normal file
46
web/src/lib/components/photos/immich-thumbnail.svelte
Normal file
|
@ -0,0 +1,46 @@
|
|||
<script lang="ts">
|
||||
import type { ImmichAsset } from '../../models/immich-asset';
|
||||
import { session } from '$app/stores';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { serverEndpoint } from '../../constants';
|
||||
import IntersectionObserver from '$lib/components/photos/intersection-observer.svelte';
|
||||
|
||||
export let asset: ImmichAsset;
|
||||
let imageContent: string;
|
||||
|
||||
const loadImageData = async () => {
|
||||
if ($session.user) {
|
||||
const res = await fetch(serverEndpoint + '/asset/thumbnail/' + asset.id, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'bearer ' + $session.user.accessToken,
|
||||
},
|
||||
});
|
||||
|
||||
imageContent = URL.createObjectURL(await res.blob());
|
||||
|
||||
return imageContent;
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => URL.revokeObjectURL(imageContent));
|
||||
</script>
|
||||
|
||||
<IntersectionObserver once={true} let:intersecting>
|
||||
<div class="h-[200px] w-[200px] bg-gray-100">
|
||||
{#if intersecting}
|
||||
{#await loadImageData()}
|
||||
<div class="bg-immich-primary/10 h-[200px] w-[200px] flex place-items-center place-content-center">...</div>
|
||||
{:then imageData}
|
||||
<img
|
||||
in:fade={{ duration: 200 }}
|
||||
src={imageData}
|
||||
alt={asset.id}
|
||||
class="object-cover h-[200px] w-[200px] transition-all duration-100"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
</IntersectionObserver>
|
55
web/src/lib/components/photos/intersection-observer.svelte
Normal file
55
web/src/lib/components/photos/intersection-observer.svelte
Normal file
|
@ -0,0 +1,55 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let once = false;
|
||||
export let top = 0;
|
||||
export let bottom = 0;
|
||||
export let left = 0;
|
||||
export let right = 0;
|
||||
|
||||
let intersecting = false;
|
||||
let container: any;
|
||||
|
||||
onMount(() => {
|
||||
if (typeof IntersectionObserver !== 'undefined') {
|
||||
const rootMargin = `${bottom}px ${left}px ${top}px ${right}px`;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
intersecting = entries[0].isIntersecting;
|
||||
if (intersecting && once) {
|
||||
observer.unobserve(container);
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin,
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(container);
|
||||
return () => observer.unobserve(container);
|
||||
}
|
||||
|
||||
// The following is a fallback for older browsers
|
||||
function handler() {
|
||||
const bcr = container.getBoundingClientRect();
|
||||
|
||||
intersecting =
|
||||
bcr.bottom + bottom > 0 &&
|
||||
bcr.right + right > 0 &&
|
||||
bcr.top - top < window.innerHeight &&
|
||||
bcr.left - left < window.innerWidth;
|
||||
|
||||
if (intersecting && once) {
|
||||
window.removeEventListener('scroll', handler);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handler);
|
||||
return () => window.removeEventListener('scroll', handler);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={container}>
|
||||
<slot {intersecting} />
|
||||
</div>
|
|
@ -16,10 +16,10 @@
|
|||
<div class="flex border place-items-center px-6 py-2 ">
|
||||
<a class="flex gap-2 place-items-center hover:cursor-pointer" href="/photos">
|
||||
<img src="/immich-logo.svg" alt="immich logo" height="35" width="35" />
|
||||
<h1 class="font-immich-title text-2xl text-immich-primary">Immich</h1>
|
||||
<h1 class="font-immich-title text-2xl text-immich-primary">IMMICH</h1>
|
||||
</a>
|
||||
<div class="flex-1 ml-24">
|
||||
<div class="w-[50%] border rounded-md bg-gray-200 px-8 py-4">Search</div>
|
||||
<input class="w-[50%] border rounded-md bg-gray-200 px-8 py-4" placeholder="Search - Coming soon" />
|
||||
</div>
|
||||
<section class="flex gap-6 place-items-center">
|
||||
<!-- <div>Upload</div> -->
|
||||
|
|
54
web/src/lib/models/immich-asset.ts
Normal file
54
web/src/lib/models/immich-asset.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
export enum AssetType {
|
||||
IMAGE = 'IMAGE',
|
||||
VIDEO = 'VIDEO',
|
||||
AUDIO = 'AUDIO',
|
||||
OTHER = 'OTHER',
|
||||
}
|
||||
|
||||
export type ImmichExif = {
|
||||
id: string;
|
||||
assetId: string;
|
||||
make: string;
|
||||
model: string;
|
||||
imageName: string;
|
||||
exifImageWidth: number;
|
||||
exifImageHeight: number;
|
||||
fileSizeInByte: number;
|
||||
orientation: string;
|
||||
dateTimeOriginal: Date;
|
||||
modifyDate: Date;
|
||||
lensModel: string;
|
||||
fNumber: number;
|
||||
focalLength: number;
|
||||
iso: number;
|
||||
exposureTime: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
city: string;
|
||||
state: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
export type ImmichAssetSmartInfo = {
|
||||
id: string;
|
||||
assetId: string;
|
||||
tags: string[];
|
||||
objects: string[];
|
||||
}
|
||||
|
||||
export type ImmichAsset = {
|
||||
id: string;
|
||||
deviceAssetId: string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
type: AssetType;
|
||||
originalPath: string;
|
||||
resizePath: string;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
isFavorite: boolean;
|
||||
mimeType: string;
|
||||
duration: string;
|
||||
exifInfo?: ImmichExif;
|
||||
smartInfo?: ImmichAssetSmartInfo;
|
||||
}
|
28
web/src/lib/stores/assets.ts
Normal file
28
web/src/lib/stores/assets.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { writable, derived } from 'svelte/store';
|
||||
import { getRequest } from '$lib/api';
|
||||
import type { ImmichAsset } from '$lib/models/immich-asset'
|
||||
import * as _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
|
||||
const assets = writable<ImmichAsset[]>([]);
|
||||
|
||||
const assetsGroupByDate = derived(assets, ($assets) => {
|
||||
|
||||
return _.chain($assets)
|
||||
.groupBy((a) => moment(a.createdAt).format('ddd, MMM DD'))
|
||||
.sortBy((group) => $assets.indexOf(group[0]))
|
||||
.value();
|
||||
|
||||
})
|
||||
|
||||
const getAssetsInfo = async (accessToken: string) => {
|
||||
const res = await getRequest('asset', accessToken);
|
||||
|
||||
assets.set(res);
|
||||
}
|
||||
|
||||
export default {
|
||||
assets,
|
||||
assetsGroupByDate,
|
||||
getAssetsInfo,
|
||||
}
|
|
@ -23,7 +23,7 @@
|
|||
</main>
|
||||
|
||||
<footer
|
||||
class="text-sm fixed bottom-0 h-8 flex place-items-center place-content-center bg-immich-primary/10 w-screen font-mono gap-8 px-4 font-medium"
|
||||
class="text-sm fixed bottom-0 h-8 flex place-items-center place-content-center bg-gray-50 w-screen font-mono gap-8 px-4 font-medium"
|
||||
>
|
||||
<p class="">
|
||||
Server URL <span class="text-immich-primary font-bold">{endpoint}</span>
|
||||
|
|
|
@ -53,7 +53,7 @@
|
|||
<div class="flex place-items-center place-content-center ">
|
||||
<img class="text-center" src="immich-logo.svg" height="200" width="200" alt="immich-logo" />
|
||||
</div>
|
||||
<h1 class="text-4xl text-immich-primary font-bold font-immich-title">Welcome to Immich Web</h1>
|
||||
<h1 class="text-4xl text-immich-primary font-bold font-immich-title">Welcome to IMMICH Web</h1>
|
||||
<button
|
||||
class="border px-4 py-2 rounded-md bg-immich-primary hover:bg-immich-primary/75 text-white font-bold w-[200px]"
|
||||
on:click={onGettingStartedClicked}>Getting Started</button
|
||||
|
|
|
@ -26,17 +26,36 @@
|
|||
import Magnify from 'svelte-material-icons/Magnify.svelte';
|
||||
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
|
||||
import { AppSideBarSelection } from '$lib/models/admin-sidebar-selection';
|
||||
import { onMount } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { session } from '$app/stores';
|
||||
import assetStore from '$lib/stores/assets';
|
||||
import type { ImmichAsset } from '../../lib/models/immich-asset';
|
||||
import ImmichThumbnail from '../../lib/components/photos/immich-thumbnail.svelte';
|
||||
import moment from 'moment';
|
||||
|
||||
export let user: ImmichUser;
|
||||
let selectedAction: AppSideBarSelection;
|
||||
let assets: ImmichAsset[] = [];
|
||||
let assetsGroupByDate: ImmichAsset[][];
|
||||
|
||||
// Subscribe to store values
|
||||
const assetsSub = assetStore.assets.subscribe((newAssets) => (assets = newAssets));
|
||||
const assetsGroupByDateSub = assetStore.assetsGroupByDate.subscribe((value) => (assetsGroupByDate = value));
|
||||
|
||||
const onButtonClicked = (buttonType: CustomEvent) => {
|
||||
selectedAction = buttonType.detail['actionType'] as AppSideBarSelection;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
onMount(async () => {
|
||||
selectedAction = AppSideBarSelection.PHOTOS;
|
||||
if ($session.user) {
|
||||
await assetStore.getAssetsInfo($session.user.accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
assetsSub();
|
||||
assetsGroupByDateSub();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -58,18 +77,31 @@
|
|||
on:selected={onButtonClicked}
|
||||
/>
|
||||
|
||||
<SideBarButton
|
||||
<!-- <SideBarButton
|
||||
title="Explore"
|
||||
logo={Magnify}
|
||||
actionType={AppSideBarSelection.EXPLORE}
|
||||
isSelected={selectedAction === AppSideBarSelection.EXPLORE}
|
||||
on:selected={onButtonClicked}
|
||||
/>
|
||||
/> -->
|
||||
</section>
|
||||
|
||||
<section class="overflow-y-auto relative">
|
||||
<section id="setting-content" class="relative pt-[85px]">
|
||||
<section class="pt-4">Coming soon</section>
|
||||
<section id="assets-content" class="relative pt-8">
|
||||
<section id="image-grid" class="flex flex-wrap gap-8">
|
||||
{#each assetsGroupByDate as assetsInDateGroup}
|
||||
<div class="flex flex-col">
|
||||
<p class="font-medium text-sm text-gray-500 mb-2">
|
||||
{moment(assetsInDateGroup[0].createdAt).format('ddd, MMM DD YYYY')}
|
||||
</p>
|
||||
<div class=" flex flex-wrap gap-2">
|
||||
{#each assetsInDateGroup as asset}
|
||||
<ImmichThumbnail {asset} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
|
|
Loading…
Reference in a new issue