mirror of
https://github.com/immich-app/immich.git
synced 2025-03-01 15:11:21 +01:00
feat(web): show original uploader in shared album photo details (#3977)
* feat(web): show original uploader in shared album photo details * feat: send owner in asset by id response * chore: open api * fix: linting * fix: change to Shared By * openapi * openapi * api * styling --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
b1467bd1da
commit
b4fa60d4fd
11 changed files with 87 additions and 43 deletions
6
cli/src/api/open-api/api.ts
generated
6
cli/src/api/open-api/api.ts
generated
|
@ -645,6 +645,12 @@ export interface AssetResponseDto {
|
||||||
* @memberof AssetResponseDto
|
* @memberof AssetResponseDto
|
||||||
*/
|
*/
|
||||||
'originalPath': string;
|
'originalPath': string;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {UserResponseDto}
|
||||||
|
* @memberof AssetResponseDto
|
||||||
|
*/
|
||||||
|
'owner'?: UserResponseDto;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {string}
|
* @type {string}
|
||||||
|
|
1
mobile/openapi/doc/AssetResponseDto.md
generated
1
mobile/openapi/doc/AssetResponseDto.md
generated
|
@ -21,6 +21,7 @@ Name | Type | Description | Notes
|
||||||
**livePhotoVideoId** | **String** | | [optional]
|
**livePhotoVideoId** | **String** | | [optional]
|
||||||
**originalFileName** | **String** | |
|
**originalFileName** | **String** | |
|
||||||
**originalPath** | **String** | |
|
**originalPath** | **String** | |
|
||||||
|
**owner** | [**UserResponseDto**](UserResponseDto.md) | | [optional]
|
||||||
**ownerId** | **String** | |
|
**ownerId** | **String** | |
|
||||||
**people** | [**List<PersonResponseDto>**](PersonResponseDto.md) | | [optional] [default to const []]
|
**people** | [**List<PersonResponseDto>**](PersonResponseDto.md) | | [optional] [default to const []]
|
||||||
**resized** | **bool** | |
|
**resized** | **bool** | |
|
||||||
|
|
19
mobile/openapi/lib/model/asset_response_dto.dart
generated
19
mobile/openapi/lib/model/asset_response_dto.dart
generated
|
@ -26,6 +26,7 @@ class AssetResponseDto {
|
||||||
this.livePhotoVideoId,
|
this.livePhotoVideoId,
|
||||||
required this.originalFileName,
|
required this.originalFileName,
|
||||||
required this.originalPath,
|
required this.originalPath,
|
||||||
|
this.owner,
|
||||||
required this.ownerId,
|
required this.ownerId,
|
||||||
this.people = const [],
|
this.people = const [],
|
||||||
required this.resized,
|
required this.resized,
|
||||||
|
@ -69,6 +70,14 @@ class AssetResponseDto {
|
||||||
|
|
||||||
String originalPath;
|
String originalPath;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// Please note: This property should have been non-nullable! Since the specification file
|
||||||
|
/// does not include a default value (using the "default:" property), however, the generated
|
||||||
|
/// source code must fall back to having a nullable type.
|
||||||
|
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||||
|
///
|
||||||
|
UserResponseDto? owner;
|
||||||
|
|
||||||
String ownerId;
|
String ownerId;
|
||||||
|
|
||||||
List<PersonResponseDto> people;
|
List<PersonResponseDto> people;
|
||||||
|
@ -107,6 +116,7 @@ class AssetResponseDto {
|
||||||
other.livePhotoVideoId == livePhotoVideoId &&
|
other.livePhotoVideoId == livePhotoVideoId &&
|
||||||
other.originalFileName == originalFileName &&
|
other.originalFileName == originalFileName &&
|
||||||
other.originalPath == originalPath &&
|
other.originalPath == originalPath &&
|
||||||
|
other.owner == owner &&
|
||||||
other.ownerId == ownerId &&
|
other.ownerId == ownerId &&
|
||||||
other.people == people &&
|
other.people == people &&
|
||||||
other.resized == resized &&
|
other.resized == resized &&
|
||||||
|
@ -132,6 +142,7 @@ class AssetResponseDto {
|
||||||
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
|
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
|
||||||
(originalFileName.hashCode) +
|
(originalFileName.hashCode) +
|
||||||
(originalPath.hashCode) +
|
(originalPath.hashCode) +
|
||||||
|
(owner == null ? 0 : owner!.hashCode) +
|
||||||
(ownerId.hashCode) +
|
(ownerId.hashCode) +
|
||||||
(people.hashCode) +
|
(people.hashCode) +
|
||||||
(resized.hashCode) +
|
(resized.hashCode) +
|
||||||
|
@ -142,7 +153,7 @@ class AssetResponseDto {
|
||||||
(updatedAt.hashCode);
|
(updatedAt.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, livePhotoVideoId=$livePhotoVideoId, originalFileName=$originalFileName, originalPath=$originalPath, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]';
|
String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, livePhotoVideoId=$livePhotoVideoId, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
|
@ -167,6 +178,11 @@ class AssetResponseDto {
|
||||||
}
|
}
|
||||||
json[r'originalFileName'] = this.originalFileName;
|
json[r'originalFileName'] = this.originalFileName;
|
||||||
json[r'originalPath'] = this.originalPath;
|
json[r'originalPath'] = this.originalPath;
|
||||||
|
if (this.owner != null) {
|
||||||
|
json[r'owner'] = this.owner;
|
||||||
|
} else {
|
||||||
|
// json[r'owner'] = null;
|
||||||
|
}
|
||||||
json[r'ownerId'] = this.ownerId;
|
json[r'ownerId'] = this.ownerId;
|
||||||
json[r'people'] = this.people;
|
json[r'people'] = this.people;
|
||||||
json[r'resized'] = this.resized;
|
json[r'resized'] = this.resized;
|
||||||
|
@ -207,6 +223,7 @@ class AssetResponseDto {
|
||||||
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
|
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
|
||||||
originalFileName: mapValueOfType<String>(json, r'originalFileName')!,
|
originalFileName: mapValueOfType<String>(json, r'originalFileName')!,
|
||||||
originalPath: mapValueOfType<String>(json, r'originalPath')!,
|
originalPath: mapValueOfType<String>(json, r'originalPath')!,
|
||||||
|
owner: UserResponseDto.fromJson(json[r'owner']),
|
||||||
ownerId: mapValueOfType<String>(json, r'ownerId')!,
|
ownerId: mapValueOfType<String>(json, r'ownerId')!,
|
||||||
people: PersonResponseDto.listFromJson(json[r'people']),
|
people: PersonResponseDto.listFromJson(json[r'people']),
|
||||||
resized: mapValueOfType<bool>(json, r'resized')!,
|
resized: mapValueOfType<bool>(json, r'resized')!,
|
||||||
|
|
5
mobile/openapi/test/asset_response_dto_test.dart
generated
5
mobile/openapi/test/asset_response_dto_test.dart
generated
|
@ -82,6 +82,11 @@ void main() {
|
||||||
// TODO
|
// TODO
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// UserResponseDto owner
|
||||||
|
test('to test the property `owner`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
// String ownerId
|
// String ownerId
|
||||||
test('to test the property `ownerId`', () async {
|
test('to test the property `ownerId`', () async {
|
||||||
// TODO
|
// TODO
|
||||||
|
|
|
@ -5208,6 +5208,9 @@
|
||||||
"originalPath": {
|
"originalPath": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"owner": {
|
||||||
|
"$ref": "#/components/schemas/UserResponseDto"
|
||||||
|
},
|
||||||
"ownerId": {
|
"ownerId": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
@ -5246,8 +5249,8 @@
|
||||||
"type",
|
"type",
|
||||||
"id",
|
"id",
|
||||||
"deviceAssetId",
|
"deviceAssetId",
|
||||||
"ownerId",
|
|
||||||
"deviceId",
|
"deviceId",
|
||||||
|
"ownerId",
|
||||||
"originalPath",
|
"originalPath",
|
||||||
"originalFileName",
|
"originalFileName",
|
||||||
"resized",
|
"resized",
|
||||||
|
|
|
@ -2,14 +2,16 @@ import { AssetEntity, AssetType } from '@app/infra/entities';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { PersonResponseDto, mapFace } from '../../person/person.dto';
|
import { PersonResponseDto, mapFace } from '../../person/person.dto';
|
||||||
import { TagResponseDto, mapTag } from '../../tag';
|
import { TagResponseDto, mapTag } from '../../tag';
|
||||||
|
import { UserResponseDto, mapUser } from '../../user/response-dto/user-response.dto';
|
||||||
import { ExifResponseDto, mapExif } from './exif-response.dto';
|
import { ExifResponseDto, mapExif } from './exif-response.dto';
|
||||||
import { SmartInfoResponseDto, mapSmartInfo } from './smart-info-response.dto';
|
import { SmartInfoResponseDto, mapSmartInfo } from './smart-info-response.dto';
|
||||||
|
|
||||||
export class AssetResponseDto {
|
export class AssetResponseDto {
|
||||||
id!: string;
|
id!: string;
|
||||||
deviceAssetId!: string;
|
deviceAssetId!: string;
|
||||||
ownerId!: string;
|
|
||||||
deviceId!: string;
|
deviceId!: string;
|
||||||
|
ownerId!: string;
|
||||||
|
owner?: UserResponseDto;
|
||||||
|
|
||||||
@ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
|
@ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
|
||||||
type!: AssetType;
|
type!: AssetType;
|
||||||
|
@ -33,11 +35,12 @@ export class AssetResponseDto {
|
||||||
checksum!: string;
|
checksum!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapAsset(entity: AssetEntity): AssetResponseDto {
|
function _map(entity: AssetEntity, withExif: boolean): AssetResponseDto {
|
||||||
return {
|
return {
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
deviceAssetId: entity.deviceAssetId,
|
deviceAssetId: entity.deviceAssetId,
|
||||||
ownerId: entity.ownerId,
|
ownerId: entity.ownerId,
|
||||||
|
owner: entity.owner ? mapUser(entity.owner) : undefined,
|
||||||
deviceId: entity.deviceId,
|
deviceId: entity.deviceId,
|
||||||
type: entity.type,
|
type: entity.type,
|
||||||
originalPath: entity.originalPath,
|
originalPath: entity.originalPath,
|
||||||
|
@ -50,7 +53,7 @@ export function mapAsset(entity: AssetEntity): AssetResponseDto {
|
||||||
isFavorite: entity.isFavorite,
|
isFavorite: entity.isFavorite,
|
||||||
isArchived: entity.isArchived,
|
isArchived: entity.isArchived,
|
||||||
duration: entity.duration ?? '0:00:00.00000',
|
duration: entity.duration ?? '0:00:00.00000',
|
||||||
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
|
exifInfo: withExif ? (entity.exifInfo ? mapExif(entity.exifInfo) : undefined) : undefined,
|
||||||
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
|
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
|
||||||
livePhotoVideoId: entity.livePhotoVideoId,
|
livePhotoVideoId: entity.livePhotoVideoId,
|
||||||
tags: entity.tags?.map(mapTag),
|
tags: entity.tags?.map(mapTag),
|
||||||
|
@ -59,30 +62,12 @@ export function mapAsset(entity: AssetEntity): AssetResponseDto {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mapAsset(entity: AssetEntity): AssetResponseDto {
|
||||||
|
return _map(entity, true);
|
||||||
|
}
|
||||||
|
|
||||||
export function mapAssetWithoutExif(entity: AssetEntity): AssetResponseDto {
|
export function mapAssetWithoutExif(entity: AssetEntity): AssetResponseDto {
|
||||||
return {
|
return _map(entity, false);
|
||||||
id: entity.id,
|
|
||||||
deviceAssetId: entity.deviceAssetId,
|
|
||||||
ownerId: entity.ownerId,
|
|
||||||
deviceId: entity.deviceId,
|
|
||||||
type: entity.type,
|
|
||||||
originalPath: entity.originalPath,
|
|
||||||
originalFileName: entity.originalFileName,
|
|
||||||
resized: !!entity.resizePath,
|
|
||||||
thumbhash: entity.thumbhash?.toString('base64') || null,
|
|
||||||
fileCreatedAt: entity.fileCreatedAt,
|
|
||||||
fileModifiedAt: entity.fileModifiedAt,
|
|
||||||
updatedAt: entity.updatedAt,
|
|
||||||
isFavorite: entity.isFavorite,
|
|
||||||
isArchived: entity.isArchived,
|
|
||||||
duration: entity.duration ?? '0:00:00.00000',
|
|
||||||
exifInfo: undefined,
|
|
||||||
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
|
|
||||||
livePhotoVideoId: entity.livePhotoVideoId,
|
|
||||||
tags: entity.tags?.map(mapTag),
|
|
||||||
people: entity.faces?.map(mapFace),
|
|
||||||
checksum: entity.checksum.toString('base64'),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MemoryLaneResponseDto {
|
export class MemoryLaneResponseDto {
|
||||||
|
|
|
@ -107,6 +107,7 @@ export class AssetRepository implements IAssetRepository {
|
||||||
tags: true,
|
tags: true,
|
||||||
sharedLinks: true,
|
sharedLinks: true,
|
||||||
smartInfo: true,
|
smartInfo: true,
|
||||||
|
owner: true,
|
||||||
faces: {
|
faces: {
|
||||||
person: true,
|
person: true,
|
||||||
},
|
},
|
||||||
|
|
|
@ -199,6 +199,10 @@ export class AssetService {
|
||||||
data.people = [];
|
data.people = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (authUser.isPublicUser) {
|
||||||
|
delete data.owner;
|
||||||
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
4
server/test/fixtures/shared-link.stub.ts
vendored
4
server/test/fixtures/shared-link.stub.ts
vendored
|
@ -1,5 +1,5 @@
|
||||||
import { AlbumResponseDto, AssetResponseDto, ExifResponseDto, mapUser, SharedLinkResponseDto } from '@app/domain';
|
import { AlbumResponseDto, AssetResponseDto, ExifResponseDto, mapUser, SharedLinkResponseDto } from '@app/domain';
|
||||||
import { AssetType, SharedLinkEntity, SharedLinkType } from '@app/infra/entities';
|
import { AssetType, SharedLinkEntity, SharedLinkType, UserEntity } from '@app/infra/entities';
|
||||||
import { assetStub } from './asset.stub';
|
import { assetStub } from './asset.stub';
|
||||||
import { authStub } from './auth.stub';
|
import { authStub } from './auth.stub';
|
||||||
import { userStub } from './user.stub';
|
import { userStub } from './user.stub';
|
||||||
|
@ -158,7 +158,7 @@ export const sharedLinkStub = {
|
||||||
assets: [
|
assets: [
|
||||||
{
|
{
|
||||||
id: 'id_1',
|
id: 'id_1',
|
||||||
owner: userStub.user1,
|
owner: undefined as unknown as UserEntity,
|
||||||
ownerId: 'user_id_1',
|
ownerId: 'user_id_1',
|
||||||
deviceAssetId: 'device_asset_id_1',
|
deviceAssetId: 'device_asset_id_1',
|
||||||
deviceId: 'device_id_1',
|
deviceId: 'device_id_1',
|
||||||
|
|
6
web/src/api/open-api/api.ts
generated
6
web/src/api/open-api/api.ts
generated
|
@ -645,6 +645,12 @@ export interface AssetResponseDto {
|
||||||
* @memberof AssetResponseDto
|
* @memberof AssetResponseDto
|
||||||
*/
|
*/
|
||||||
'originalPath': string;
|
'originalPath': string;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {UserResponseDto}
|
||||||
|
* @memberof AssetResponseDto
|
||||||
|
*/
|
||||||
|
'owner'?: UserResponseDto;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {string}
|
* @type {string}
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
import { asByteUnitString } from '../../utils/byte-units';
|
import { asByteUnitString } from '../../utils/byte-units';
|
||||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||||
import { getAssetFilename } from '$lib/utils/asset-utils';
|
import { getAssetFilename } from '$lib/utils/asset-utils';
|
||||||
|
import UserAvatar from '../shared-components/user-avatar.svelte';
|
||||||
|
|
||||||
export let asset: AssetResponseDto;
|
export let asset: AssetResponseDto;
|
||||||
export let albums: AlbumResponseDto[] = [];
|
export let albums: AlbumResponseDto[] = [];
|
||||||
|
@ -20,6 +21,8 @@
|
||||||
let textarea: HTMLTextAreaElement;
|
let textarea: HTMLTextAreaElement;
|
||||||
let description: string;
|
let description: string;
|
||||||
|
|
||||||
|
$: isOwner = $page?.data?.user?.id === asset.ownerId;
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
// Get latest description from server
|
// Get latest description from server
|
||||||
if (asset.id && !api.isSharedLink) {
|
if (asset.id && !api.isSharedLink) {
|
||||||
|
@ -93,20 +96,17 @@
|
||||||
<p class="text-lg text-immich-fg dark:text-immich-dark-fg">Info</p>
|
<p class="text-lg text-immich-fg dark:text-immich-dark-fg">Info</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section
|
<section class="mx-4 mt-10" style:display={!isOwner && textarea?.value == '' ? 'none' : 'block'}>
|
||||||
class="mx-4 mt-10"
|
|
||||||
style:display={$page?.data?.user?.id !== asset.ownerId && textarea?.value == '' ? 'none' : 'block'}
|
|
||||||
>
|
|
||||||
<textarea
|
<textarea
|
||||||
bind:this={textarea}
|
bind:this={textarea}
|
||||||
class="max-h-[500px]
|
class="max-h-[500px]
|
||||||
w-full resize-none overflow-hidden border-b border-gray-500 bg-transparent text-base text-black outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:text-white dark:focus:border-immich-dark-primary"
|
w-full resize-none overflow-hidden border-b border-gray-500 bg-transparent text-base text-black outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:text-white dark:focus:border-immich-dark-primary"
|
||||||
placeholder={$page?.data?.user?.id !== asset.ownerId ? '' : 'Add a description'}
|
placeholder={!isOwner ? '' : 'Add a description'}
|
||||||
on:focusin={handleFocusIn}
|
on:focusin={handleFocusIn}
|
||||||
on:focusout={handleFocusOut}
|
on:focusout={handleFocusOut}
|
||||||
on:input={autoGrowHeight}
|
on:input={autoGrowHeight}
|
||||||
bind:value={description}
|
bind:value={description}
|
||||||
disabled={$page?.data?.user?.id !== asset.ownerId}
|
disabled={!isOwner}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@ -291,11 +291,27 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section class="p-2 dark:text-immich-dark-fg">
|
{#if asset.owner && !isOwner}
|
||||||
<div class="px-4 py-4">
|
<section class="px-6 pt-6 dark:text-immich-dark-fg">
|
||||||
{#if albums.length > 0}
|
<p class="text-sm">SHARED BY</p>
|
||||||
<p class="pb-4 text-sm">APPEARS IN</p>
|
<div class="flex gap-4 pt-4">
|
||||||
{/if}
|
<div>
|
||||||
|
<UserAvatar user={asset.owner} size="md" autoColor />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-auto mt-auto">
|
||||||
|
<p>
|
||||||
|
{asset.owner.firstName}
|
||||||
|
{asset.owner.lastName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if albums.length > 0}
|
||||||
|
<section class="p-6 dark:text-immich-dark-fg">
|
||||||
|
<p class="pb-4 text-sm">APPEARS IN</p>
|
||||||
{#each albums as album}
|
{#each albums as album}
|
||||||
<a data-sveltekit-preload-data="hover" href={`/albums/${album.id}`}>
|
<a data-sveltekit-preload-data="hover" href={`/albums/${album.id}`}>
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
|
@ -326,5 +342,5 @@
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</section>
|
||||||
</section>
|
{/if}
|
||||||
|
|
Loading…
Add table
Reference in a new issue