فهرست منبع

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
Alex 3 سال پیش
والد
کامیت
6023c3c624

+ 8 - 3
.github/workflows/build_push_docker_latest.yml

@@ -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

+ 1 - 1
README.md

@@ -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
 

+ 1 - 1
server/src/api-v1/asset/asset.service.ts

@@ -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, {

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 63 - 501
web/package-lock.json


+ 3 - 3
web/package.json

@@ -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"
 	}
 }

+ 16 - 9
web/src/lib/api.ts

@@ -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 - 0
web/src/lib/components/photos/immich-thumbnail.svelte

@@ -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 - 0
web/src/lib/components/photos/intersection-observer.svelte

@@ -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>

+ 2 - 2
web/src/lib/components/shared/navigation-bar.svelte

@@ -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 - 0
web/src/lib/models/immich-asset.ts

@@ -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 - 0
web/src/lib/stores/assets.ts

@@ -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,
+}

+ 1 - 1
web/src/routes/__layout.svelte

@@ -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>

+ 1 - 1
web/src/routes/index.svelte

@@ -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

+ 38 - 6
web/src/routes/photos/index.svelte

@@ -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>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است